@keyou007/agent-bot 0.1.2 → 0.1.3

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 (67) hide show
  1. package/.env.example +3 -1
  2. package/CHANGELOG.md +9 -0
  3. package/README.md +20 -7
  4. package/README.zh.md +20 -7
  5. package/config.example.yaml +1 -0
  6. package/dist/cli/FeishuAppRegistration.js +12 -2
  7. package/dist/cli/FeishuAppRegistration.js.map +1 -1
  8. package/dist/cli/Initializer.js +10 -4
  9. package/dist/cli/Initializer.js.map +1 -1
  10. package/dist/cli/ServerStarter.js +69 -0
  11. package/dist/cli/ServerStarter.js.map +1 -0
  12. package/dist/cli/help.js +6 -2
  13. package/dist/cli/help.js.map +1 -1
  14. package/dist/cli/profile.js +40 -0
  15. package/dist/cli/profile.js.map +1 -0
  16. package/dist/cli.js +40 -56
  17. package/dist/cli.js.map +1 -1
  18. package/dist/config/loadConfig.js +7 -2
  19. package/dist/config/loadConfig.js.map +1 -1
  20. package/dist/config/paths.js +1 -0
  21. package/dist/config/paths.js.map +1 -1
  22. package/dist/config/schema.js +1 -0
  23. package/dist/config/schema.js.map +1 -1
  24. package/dist/feishu/CardRenderer.js +21 -3
  25. package/dist/feishu/CardRenderer.js.map +1 -1
  26. package/dist/feishu/FeishuConnector.js +7 -76
  27. package/dist/feishu/FeishuConnector.js.map +1 -1
  28. package/dist/index.js +13 -5
  29. package/dist/index.js.map +1 -1
  30. package/dist/proxy/ProxySessionController.js +18 -3
  31. package/dist/proxy/ProxySessionController.js.map +1 -1
  32. package/dist/startup/StartupNotifier.js +13 -2
  33. package/dist/startup/StartupNotifier.js.map +1 -1
  34. package/dist/startup/startFeishu.js +1 -1
  35. package/dist/startup/startFeishu.js.map +1 -1
  36. package/dist/supervision/SafeRestartNotifier.js +23 -7
  37. package/dist/supervision/SafeRestartNotifier.js.map +1 -1
  38. package/dist/supervision/SafeRestartScheduler.js +20 -2
  39. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  40. package/dist/supervision/SupervisorDiagnostics.js +209 -0
  41. package/dist/supervision/SupervisorDiagnostics.js.map +1 -0
  42. package/dist/supervisor.js +47 -12
  43. package/dist/supervisor.js.map +1 -1
  44. package/docs/technical-reference.md +23 -5
  45. package/docs/technical-reference.zh.md +23 -5
  46. package/npm-shrinkwrap.json +2 -2
  47. package/package.json +1 -1
  48. package/skills/agent-bot/SKILL.md +23 -3
  49. package/src/cli/FeishuAppRegistration.ts +14 -1
  50. package/src/cli/Initializer.ts +10 -3
  51. package/src/cli/ServerStarter.ts +106 -0
  52. package/src/cli/help.ts +6 -2
  53. package/src/cli/profile.ts +59 -0
  54. package/src/cli.ts +51 -52
  55. package/src/config/loadConfig.ts +12 -2
  56. package/src/config/paths.ts +1 -0
  57. package/src/config/schema.ts +1 -0
  58. package/src/feishu/CardRenderer.ts +25 -3
  59. package/src/feishu/FeishuConnector.ts +6 -87
  60. package/src/index.ts +17 -5
  61. package/src/proxy/ProxySessionController.ts +18 -3
  62. package/src/startup/StartupNotifier.ts +21 -4
  63. package/src/startup/startFeishu.ts +1 -1
  64. package/src/supervision/SafeRestartNotifier.ts +22 -7
  65. package/src/supervision/SafeRestartScheduler.ts +27 -3
  66. package/src/supervision/SupervisorDiagnostics.ts +268 -0
  67. package/src/supervisor.ts +46 -11
package/src/cli.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { spawn, spawnSync } from "node:child_process";
2
+ import { spawnSync } from "node:child_process";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import qrcode from "qrcode-terminal";
5
5
  import { loadConfig } from "./config/loadConfig.js";
6
- import { sendControlRequest, isServerReachable, isServerRunning } from "./cli/LocalControlClient.js";
6
+ import { sendControlRequest, isServerReachable } from "./cli/LocalControlClient.js";
7
7
  import {
8
8
  controlEndpoint,
9
9
  type ControlResponse,
@@ -30,11 +30,22 @@ import {
30
30
  type FeishuConfigurationChallenge,
31
31
  } from "./cli/FeishuAppConfiguration.js";
32
32
  import { renderCliHelp } from "./cli/help.js";
33
- import { requireServerFeishuTransport } from "./feishu/transport.js";
34
33
  import { resolveSystemSkillsRoot, SkillRegistry, type SkillRegistrationStatus } from "./cli/SkillRegistry.js";
35
34
  import { readPackageVersion } from "./cli/packageVersion.js";
35
+ import { applyExplicitProfile, parseGlobalOptions } from "./cli/profile.js";
36
+ import {
37
+ startInitializedServer,
38
+ startServer,
39
+ type InitializationServerResult,
40
+ type ServerStartResult,
41
+ } from "./cli/ServerStarter.js";
36
42
  import { taskChatRoute } from "./cli/taskChatRoute.js";
37
43
  import { StateStore, type SessionRecord } from "./state/StateStore.js";
44
+ import {
45
+ nodeDiagnosticReportArguments,
46
+ prepareCrashReportDirectory,
47
+ resolveSupervisorDiagnosticsPaths,
48
+ } from "./supervision/SupervisorDiagnostics.js";
38
49
 
39
50
  const args = process.argv.slice(2);
40
51
 
@@ -45,7 +56,8 @@ void main(args).catch((error: unknown) => {
45
56
 
46
57
  async function main(input: string[]): Promise<void> {
47
58
  const parsed = parseGlobalOptions(input);
48
- if (parsed.configPath) process.env.AGENT_BOT_CONFIG = parsed.configPath;
59
+ if (parsed.profilePath) applyExplicitProfile(parsed.profilePath);
60
+ else if (parsed.configPath) process.env.AGENT_BOT_CONFIG = parsed.configPath;
49
61
  const [command, ...rest] = parsed.args;
50
62
  if (!command || command === "help" || command === "--help" || command === "-h") {
51
63
  printHelp();
@@ -86,6 +98,7 @@ interface InitCommandResult extends InitializationResult {
86
98
  appId?: string;
87
99
  configuration?: EnsureFeishuAppConfigurationResult;
88
100
  };
101
+ server: InitializationServerResult;
89
102
  }
90
103
 
91
104
  async function initCommand(input: string[], configPath?: string): Promise<void> {
@@ -103,18 +116,24 @@ async function initCommand(input: string[], configPath?: string): Promise<void>
103
116
  if (!json) printInitializationPaths(paths);
104
117
  const initializationLock = acquireInitializationLock(paths.home.path);
105
118
 
119
+ let initialized: Omit<InitCommandResult, "server">;
106
120
  try {
107
121
  cleanupFeishuCredentialTemporaryFiles(paths.env.path);
108
122
  const feishu = await initializeFeishu(
109
123
  paths,
110
124
  { json, skipFeishu, reconfigureFeishu },
111
125
  );
112
- const result: InitCommandResult = { ...paths, feishu };
113
- if (json) printJson(result);
114
- else printInitializationResult(result);
126
+ initialized = { ...paths, feishu };
115
127
  } finally {
116
128
  initializationLock.release();
117
129
  }
130
+
131
+ if (!json) printInitializationResult(initialized);
132
+ if (!json && !skipFeishu) process.stdout.write("\n正在启动 Agent Bot server...\n");
133
+ const server = await startInitializedServer({ skipFeishu, configPath });
134
+ const result: InitCommandResult = { ...initialized, server };
135
+ if (json) printJson(result);
136
+ else printInitializationServerResult(server);
118
137
  }
119
138
 
120
139
  async function initializeFeishu(
@@ -140,6 +159,7 @@ async function initializeFeishu(
140
159
  credentials = {
141
160
  appId: existing.appId!,
142
161
  appSecret: existing.appSecret!,
162
+ userOpenId: existing.userOpenId,
143
163
  };
144
164
  status = "existing";
145
165
  } else {
@@ -220,7 +240,12 @@ async function consoleCommand(input: string[]): Promise<void> {
220
240
  throw new Error("agent-bot server 正在运行。为避免争用同一任务状态,请先停止 server,或明确使用 --force。");
221
241
  }
222
242
  const entry = fileURLToPath(new URL("./index.js", import.meta.url));
223
- const result = spawnSync(process.execPath, [entry], {
243
+ const reportDirectory = resolveSupervisorDiagnosticsPaths(config).crashReportDirectory;
244
+ prepareCrashReportDirectory(reportDirectory);
245
+ const result = spawnSync(process.execPath, [
246
+ ...nodeDiagnosticReportArguments(reportDirectory),
247
+ entry,
248
+ ], {
224
249
  cwd: process.cwd(),
225
250
  stdio: "inherit",
226
251
  env: { ...process.env, AGENT_BOT_CONSOLE_ONLY: "1" },
@@ -247,29 +272,7 @@ async function serverCommand(input: string[]): Promise<void> {
247
272
  return;
248
273
  }
249
274
  if (action === "start") {
250
- requireServerFeishuTransport(config.feishu);
251
- if (await isServerRunning(endpoint)) {
252
- process.stdout.write("agent-bot server 已在运行。\n");
253
- return;
254
- }
255
- if (await isServerReachable(endpoint)) {
256
- const running = await waitForServer(endpoint, 45_000);
257
- if (!running) throw new Error("agent-bot server 已启动,但未能连接飞书机器人。请检查日志。");
258
- process.stdout.write("agent-bot server 已启动。\n");
259
- return;
260
- }
261
- const entry = fileURLToPath(new URL("./supervisor.js", import.meta.url));
262
- const child = spawn(process.execPath, [entry], {
263
- cwd: process.cwd(),
264
- detached: true,
265
- windowsHide: true,
266
- stdio: "ignore",
267
- env: { ...process.env, AGENT_BOT_RESTART_REASON: "通过 agent-bot CLI 启动" },
268
- });
269
- child.unref();
270
- const running = await waitForServer(endpoint, 45_000);
271
- if (!running) throw new Error("已启动 Supervisor,但 server 未在 45 秒内连接飞书机器人。请检查日志。");
272
- process.stdout.write("agent-bot server 已启动。\n");
275
+ printServerStartResult(await startServer(config));
273
276
  return;
274
277
  }
275
278
  if (action === "stop") {
@@ -374,16 +377,6 @@ async function taskCommand(input: string[]): Promise<void> {
374
377
  }
375
378
  }
376
379
 
377
- function parseGlobalOptions(input: string[]): { configPath?: string; args: string[] } {
378
- const args = [...input];
379
- const configIndex = args.indexOf("--config");
380
- if (configIndex < 0) return { args };
381
- const configPath = args[configIndex + 1];
382
- if (!configPath) throw new Error("--config 需要配置文件路径。");
383
- args.splice(configIndex, 2);
384
- return { configPath, args };
385
- }
386
-
387
380
  function filterSessions(sessions: SessionRecord[], args: string[]): SessionRecord[] {
388
381
  const context = optionValue(args, "--context");
389
382
  const status = optionValue(args, "--status");
@@ -481,7 +474,7 @@ function printInitializationPaths(result: InitializationResult): void {
481
474
  process.stdout.write(`日志目录:${result.logs.path}(${initializationStatusLabel(result.logs.status)})\n`);
482
475
  }
483
476
 
484
- function printInitializationResult(result: InitCommandResult): void {
477
+ function printInitializationResult(result: Omit<InitCommandResult, "server">): void {
485
478
  process.stdout.write("\nAgent Bot 初始化完成。\n");
486
479
  if (result.feishu.status === "created") {
487
480
  process.stdout.write(`飞书应用:已创建并保存凭证(${result.feishu.appId})\n`);
@@ -511,7 +504,22 @@ function printInitializationResult(result: InitCommandResult): void {
511
504
  process.stdout.write("飞书配置:权限、事件和回调均已就绪\n");
512
505
  }
513
506
  process.stdout.write(`配置文件:${result.config.path}\n`);
514
- process.stdout.write("下一步:运行 agent-bot server start。\n");
507
+ }
508
+
509
+ function printInitializationServerResult(result: InitCommandResult["server"]): void {
510
+ if (result.status === "skipped") {
511
+ process.stdout.write("Agent Bot server:已跳过(未配置飞书,可使用 Console 模式)\n");
512
+ return;
513
+ }
514
+ printServerStartResult(result);
515
+ }
516
+
517
+ function printServerStartResult(result: ServerStartResult): void {
518
+ process.stdout.write(
519
+ result.status === "already-running"
520
+ ? "agent-bot server 已在运行。\n"
521
+ : "agent-bot server 已启动。\n",
522
+ );
515
523
  }
516
524
 
517
525
  function printFeishuVerification(challenge: FeishuAppRegistrationChallenge, json: boolean): void {
@@ -617,15 +625,6 @@ function optionValue(args: string[], name: string): string | undefined {
617
625
  return index >= 0 ? args[index + 1] : undefined;
618
626
  }
619
627
 
620
- async function waitForServer(endpoint: string, timeoutMs: number): Promise<boolean> {
621
- const deadline = Date.now() + timeoutMs;
622
- while (Date.now() < deadline) {
623
- if (await isServerRunning(endpoint)) return true;
624
- await new Promise((resolve) => setTimeout(resolve, 200));
625
- }
626
- return false;
627
- }
628
-
629
628
  function printJson(value: unknown): void {
630
629
  process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
631
630
  }
@@ -3,12 +3,18 @@ import path from "node:path";
3
3
  import { config as loadDotEnv } from "dotenv";
4
4
  import YAML from "yaml";
5
5
  import { appConfigSchema, type AppConfig } from "./schema.js";
6
- import { defaultConfigPath, defaultDotEnvPath, resolveUserPath } from "./paths.js";
6
+ import {
7
+ AGENT_BOT_EXPLICIT_PROFILE_ENV,
8
+ defaultConfigPath,
9
+ defaultDotEnvPath,
10
+ resolveUserPath,
11
+ } from "./paths.js";
7
12
 
8
13
  const ENV_PATTERN = /\$\{([A-Z0-9_]+)\}/gi;
9
14
  const DEFAULT_CONFIG = `feishu:
10
15
  appId: "\${FEISHU_APP_ID}"
11
16
  appSecret: "\${FEISHU_APP_SECRET}"
17
+ userOpenId: "\${FEISHU_USER_OPEN_ID}"
12
18
 
13
19
  console:
14
20
  enabled: true
@@ -65,7 +71,11 @@ logging:
65
71
  export function loadConfig(
66
72
  configPath?: string,
67
73
  ): AppConfig {
68
- loadDotEnv({ path: defaultDotEnvPath(), quiet: true });
74
+ loadDotEnv({
75
+ path: defaultDotEnvPath(),
76
+ quiet: true,
77
+ override: process.env[AGENT_BOT_EXPLICIT_PROFILE_ENV] === "1",
78
+ });
69
79
 
70
80
  const configuredPath = firstNonBlank(configPath, process.env.AGENT_BOT_CONFIG);
71
81
  const absolutePath = resolveUserPath(configuredPath ?? defaultConfigPath());
@@ -2,6 +2,7 @@ import os from "node:os";
2
2
  import path from "node:path";
3
3
 
4
4
  export const AGENT_BOT_HOME_ENV = "AGENT_BOT_HOME";
5
+ export const AGENT_BOT_EXPLICIT_PROFILE_ENV = "AGENT_BOT_EXPLICIT_PROFILE";
5
6
  export const DEFAULT_CONFIG_FILE = "config.yaml";
6
7
  export const DEFAULT_ENV_FILE = ".env";
7
8
  export const DEFAULT_SQLITE_PATH = "./data/agent-bot.sqlite";
@@ -21,6 +21,7 @@ export const appConfigSchema = z.object({
21
21
  transport: z.enum(["auto", "sdk", "console"]).default("auto"),
22
22
  appId: z.string().optional(),
23
23
  appSecret: z.string().optional(),
24
+ userOpenId: z.string().optional(),
24
25
  useConsoleWhenMissingCredentials: z.boolean().default(false),
25
26
  }),
26
27
  agents: z.record(z.string().min(1), agentConfigSchema).refine(
@@ -33,8 +33,9 @@ export interface StartupStatusView {
33
33
  }
34
34
 
35
35
  export interface SafeRestartStatusView {
36
+ scheduleId: number;
36
37
  reason: string;
37
- phase: "waiting_tasks" | "waiting_delivery" | "countdown" | "restarting";
38
+ phase: "waiting_tasks" | "waiting_delivery" | "countdown" | "restarting" | "cancelled";
38
39
  remainingMs?: number;
39
40
  pendingFinalDeliveries: number;
40
41
  waitingTasks: Array<{
@@ -270,6 +271,8 @@ export class CardRenderer {
270
271
  ? `${Math.max(0, Math.ceil((view.remainingMs ?? 0) / 1_000))}s`
271
272
  : view.phase === "restarting"
272
273
  ? "0s"
274
+ : view.phase === "cancelled"
275
+ ? "已取消"
273
276
  : "等待阻塞项清空后开始";
274
277
  const status = view.phase === "waiting_tasks"
275
278
  ? "🟠 等待任务完成"
@@ -277,7 +280,9 @@ export class CardRenderer {
277
280
  ? "🟠 等待最终结果投递"
278
281
  : view.phase === "countdown"
279
282
  ? "🟡 空闲确认中"
280
- : "🔄 正在重启";
283
+ : view.phase === "restarting"
284
+ ? "🔄 正在重启"
285
+ : "⚪ 已取消";
281
286
  const lines = [
282
287
  `**状态**:${status}`,
283
288
  `**重启原因**:${inlineCode(view.reason)}`,
@@ -296,7 +301,24 @@ export class CardRenderer {
296
301
  } else {
297
302
  elements.push({ tag: "hr" }, markdown("**当前等待的任务**:无"));
298
303
  }
299
- return sectionCard("Agent Bot 安全重启", elements, view.phase === "restarting" ? "blue" : "orange");
304
+ if (view.phase !== "restarting" && view.phase !== "cancelled") {
305
+ elements.push(
306
+ { tag: "hr" },
307
+ taskActionRow([{
308
+ text: "Cancel",
309
+ value: {
310
+ action: "safe_restart_cancel",
311
+ scheduleId: String(view.scheduleId),
312
+ },
313
+ }]),
314
+ );
315
+ }
316
+ const template = view.phase === "restarting"
317
+ ? "blue"
318
+ : view.phase === "cancelled"
319
+ ? "grey"
320
+ : "orange";
321
+ return sectionCard("Agent Bot 安全重启", elements, template);
300
322
  }
301
323
 
302
324
  renderStartupStatus(view: StartupStatusView): Record<string, unknown> {
@@ -3,18 +3,11 @@ import type { AppConfig } from "../config/schema.js";
3
3
  import { threadContextKey } from "./contextKey.js";
4
4
  import type { ChatUpdatedEvent, FeishuEventHandler, IncomingMessage } from "./types.js";
5
5
 
6
- const DEFAULT_CONNECT_TIMEOUT_MS = 30_000;
7
-
8
- interface FeishuConnectorOptions {
9
- connectTimeoutMs?: number;
10
- }
11
-
12
6
  export class FeishuConnector {
13
7
  constructor(
14
8
  private readonly config: AppConfig,
15
9
  private readonly handler: FeishuEventHandler,
16
10
  private readonly logger: Logger,
17
- private readonly options: FeishuConnectorOptions = {},
18
11
  ) {}
19
12
 
20
13
  async start(): Promise<void> {
@@ -83,86 +76,12 @@ export class FeishuConnector {
83
76
  };
84
77
  },
85
78
  });
86
- const connected = createConnectionWaiter(this.logger, this.options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);
87
- try {
88
- const wsClient = new lark.WSClient({
89
- appId,
90
- appSecret,
91
- logger: connected.sdkLogger,
92
- loggerLevel: lark.LoggerLevel.debug,
93
- });
94
- await wsClient.start({ eventDispatcher });
95
- await connected.promise;
96
- this.logger.info("Feishu WebSocket connector connected.");
97
- } finally {
98
- connected.dispose();
99
- }
100
- }
101
- }
102
-
103
- function createConnectionWaiter(
104
- logger: Logger,
105
- timeoutMs: number,
106
- ): {
107
- promise: Promise<void>;
108
- sdkLogger: {
109
- error: (...messages: unknown[]) => void;
110
- warn: (...messages: unknown[]) => void;
111
- info: (...messages: unknown[]) => void;
112
- debug: (...messages: unknown[]) => void;
113
- trace: (...messages: unknown[]) => void;
114
- };
115
- dispose: () => void;
116
- } {
117
- let resolveConnected!: () => void;
118
- let rejectConnected!: (error: Error) => void;
119
- let lastError: string | undefined;
120
- let settled = false;
121
- const promise = new Promise<void>((resolve, reject) => {
122
- resolveConnected = resolve;
123
- rejectConnected = reject;
124
- });
125
- const timer = setTimeout(() => {
126
- if (settled) return;
127
- settled = true;
128
- const detail = lastError ? ` Last SDK error: ${lastError}` : "";
129
- rejectConnected(new Error(`Timed out after ${timeoutMs}ms waiting for the Feishu WebSocket connection.${detail}`));
130
- }, timeoutMs);
131
- const logValues = (messages: unknown[]): string => messages.map(formatSdkLogValue).join(" ");
132
- const sdkLogger = {
133
- error: (...messages: unknown[]) => {
134
- lastError = logValues(messages);
135
- logger.error({ component: "feishu-websocket", sdkMessage: lastError }, "Feishu SDK error.");
136
- },
137
- warn: (...messages: unknown[]) => {
138
- logger.warn({ component: "feishu-websocket", sdkMessage: logValues(messages) }, "Feishu SDK warning.");
139
- },
140
- info: (...messages: unknown[]) => {
141
- logger.debug({ component: "feishu-websocket", sdkMessage: logValues(messages) }, "Feishu SDK status.");
142
- },
143
- debug: (...messages: unknown[]) => {
144
- if (!messages.some((message) => message === "ws connect success")) return;
145
- if (settled) return;
146
- settled = true;
147
- clearTimeout(timer);
148
- resolveConnected();
149
- },
150
- trace: () => undefined,
151
- };
152
- return {
153
- promise,
154
- sdkLogger,
155
- dispose: () => clearTimeout(timer),
156
- };
157
- }
158
-
159
- function formatSdkLogValue(value: unknown): string {
160
- if (typeof value === "string") return value;
161
- if (value instanceof Error) return value.message;
162
- try {
163
- return JSON.stringify(value);
164
- } catch {
165
- return String(value);
79
+ const wsClient = new lark.WSClient({
80
+ appId,
81
+ appSecret,
82
+ });
83
+ await wsClient.start({ eventDispatcher });
84
+ this.logger.info("Feishu WebSocket connector started.");
166
85
  }
167
86
  }
168
87
 
package/src/index.ts CHANGED
@@ -29,6 +29,11 @@ import { STOP_EXIT_CODE } from "./supervision/restartPolicy.js";
29
29
  import { replacementSupervisorEnvironment } from "./supervision/replacementSupervisor.js";
30
30
  import { SafeRestartScheduler } from "./supervision/SafeRestartScheduler.js";
31
31
  import { SafeRestartNotifier } from "./supervision/SafeRestartNotifier.js";
32
+ import {
33
+ nodeDiagnosticReportArguments,
34
+ prepareCrashReportDirectory,
35
+ resolveSupervisorDiagnosticsPaths,
36
+ } from "./supervision/SupervisorDiagnostics.js";
32
37
 
33
38
  const processStartedAt = new Date();
34
39
  const supervised = process.env.AGENT_BOT_SUPERVISED === "1";
@@ -78,6 +83,7 @@ if (feishuOutbound) {
78
83
  defaultAgentTitle: defaultAgent?.title ?? defaultAgentName,
79
84
  cwd: path.resolve(config.defaults.cwd),
80
85
  workspaceKind: defaultAgent?.kind === "codex" ? "projectless" : "project",
86
+ defaultUserOpenId: config.feishu.userOpenId,
81
87
  }, metadataHydrator);
82
88
  safeRestartNotifier = new SafeRestartNotifier(store, feishuOutbound, renderer, logger);
83
89
  }
@@ -96,16 +102,17 @@ if (routes.length === 0) throw new Error("Neither Feishu nor console input is en
96
102
  const outbound = new OutboundRouter(routes);
97
103
  let shuttingDown = false;
98
104
  let restartRequested = false;
99
- const controller = new ProxySessionController(config, store, runtimes, outbound, logger, {
100
- supervised,
101
- restart: requestRestart,
102
- });
103
105
  const safeRestart = new SafeRestartScheduler({
104
106
  readActivity: () => store.getServerActivityState(),
105
107
  onReady: (reason) => initiateRestart(reason),
106
108
  onStatus: (status) => safeRestartNotifier?.update(status),
107
109
  onStatusError: (error) => logger.warn({ error }, "Failed to publish safe restart status."),
108
110
  });
111
+ const controller = new ProxySessionController(config, store, runtimes, outbound, logger, {
112
+ supervised,
113
+ restart: requestRestart,
114
+ cancelSafeRestart: (scheduleId) => safeRestart.cancelScheduled(scheduleId),
115
+ });
109
116
 
110
117
  if (feishuOutbound) feishuConnector = new FeishuConnector(config, controller, logger);
111
118
  if (consoleEnabled) consoleConnector = new ConsoleConnector(controller, logger);
@@ -224,7 +231,12 @@ async function shutdown(exitCode: number, restartReason?: string): Promise<void>
224
231
 
225
232
  function startReplacementSupervisor(restartReason: string): void {
226
233
  const supervisorEntry = fileURLToPath(new URL("./supervisor.js", import.meta.url));
227
- const supervisor = spawn(process.execPath, [supervisorEntry], {
234
+ const reportDirectory = resolveSupervisorDiagnosticsPaths(config).crashReportDirectory;
235
+ prepareCrashReportDirectory(reportDirectory);
236
+ const supervisor = spawn(process.execPath, [
237
+ ...nodeDiagnosticReportArguments(reportDirectory),
238
+ supervisorEntry,
239
+ ], {
228
240
  cwd: process.cwd(),
229
241
  detached: true,
230
242
  windowsHide: true,
@@ -123,6 +123,7 @@ interface CreatedFeishuGroupContext {
123
123
  export interface ProxyLifecycle {
124
124
  supervised?: boolean;
125
125
  restart(contextKey: string): Promise<void>;
126
+ cancelSafeRestart?(scheduleId: number): Promise<boolean>;
126
127
  }
127
128
 
128
129
  export type ShellCommandExecutor = (command: string, cwd: string) => Promise<ShellCommandResult>;
@@ -176,9 +177,8 @@ export class ProxySessionController {
176
177
  }
177
178
 
178
179
  async onMessage(message: IncomingMessage): Promise<void> {
179
- if (message.chatId && message.chatType) {
180
- this.store.recordChatContext(baseChatContextKey(message.contextKey), message.chatType);
181
- }
180
+ // The durable deduplication claim is the only operation allowed before acknowledgement.
181
+ // It prevents event retries from adding duplicate reactions.
182
182
  if (!this.store.claimInboundEvent(message.messageId, "message")) return;
183
183
  try {
184
184
  const reactionId = await this.outbound.addReaction(
@@ -195,6 +195,9 @@ export class ProxySessionController {
195
195
  "Failed to acknowledge the incoming Feishu message with a reaction.",
196
196
  );
197
197
  }
198
+ if (message.chatId && message.chatType) {
199
+ this.store.recordChatContext(baseChatContextKey(message.contextKey), message.chatType);
200
+ }
198
201
  const replyTarget = message.replyInThread
199
202
  ? { messageId: message.messageId, replyInThread: true as const }
200
203
  : undefined;
@@ -323,6 +326,18 @@ export class ProxySessionController {
323
326
  await this.cancelSession(this.requireSession(contextKey, sessionId));
324
327
  } else if (kind === "queued_prompt_cancel") {
325
328
  await this.cancelQueuedPrompt(scopedAction);
329
+ } else if (kind === "safe_restart_cancel") {
330
+ const scheduleId = Number(scopedAction.value.scheduleId);
331
+ if (!Number.isSafeInteger(scheduleId) || scheduleId <= 0) {
332
+ throw new Error("安全重启卡片无效,请使用最新的状态卡片。");
333
+ }
334
+ if (!this.lifecycle?.cancelSafeRestart) {
335
+ throw new Error("当前运行方式不支持取消安全重启。");
336
+ }
337
+ const cancelled = await this.lifecycle.cancelSafeRestart(scheduleId);
338
+ if (!cancelled) {
339
+ await this.outbound.sendText(contextKey, "该安全重启计划已失效,请查看最新状态卡片。");
340
+ }
326
341
  } else if (kind === "session_more") {
327
342
  await this.refreshSessionsCardFromAction(scopedAction, undefined, true);
328
343
  } else if (kind === "session_switch") {
@@ -2,19 +2,25 @@ import type { Logger } from "pino";
2
2
  import { detectProjectlessWorkspace } from "../codex/ProjectlessWorkspace.js";
3
3
  import { CardRenderer } from "../feishu/CardRenderer.js";
4
4
  import type { FeishuOutbound } from "../feishu/types.js";
5
- import type { SessionRecord, StateStore } from "../state/StateStore.js";
5
+ import type { SessionRecord, StateStore, UserContextRecord } from "../state/StateStore.js";
6
6
 
7
7
  export interface StartupNotificationOptions {
8
8
  defaultAgentName: string;
9
9
  defaultAgentTitle: string;
10
10
  cwd: string;
11
11
  workspaceKind?: "project" | "projectless";
12
+ defaultUserOpenId?: string;
12
13
  }
13
14
 
14
15
  export interface StartupTaskMetadataHydrator {
15
16
  hydrate(session: SessionRecord): Promise<SessionRecord>;
16
17
  }
17
18
 
19
+ interface StartupNotificationTarget {
20
+ contextKey: string;
21
+ context?: UserContextRecord;
22
+ }
23
+
18
24
  export class StartupNotifier {
19
25
  constructor(
20
26
  private readonly store: StateStore,
@@ -26,7 +32,7 @@ export class StartupNotifier {
26
32
  ) {}
27
33
 
28
34
  async notify(startedAt: Date, restartReason: string): Promise<void> {
29
- let targets;
35
+ let targets: StartupNotificationTarget[];
30
36
  try {
31
37
  const activeGroupSince = new Date(startedAt.getTime() - 3 * 60 * 1_000);
32
38
  const chats = [
@@ -40,10 +46,16 @@ export class StartupNotifier {
40
46
  }));
41
47
  } catch (error) {
42
48
  this.logger.warn({ error }, "Failed to load startup notification targets.");
43
- return;
49
+ throw new Error("Failed to load startup notification targets.", { cause: error });
44
50
  }
45
51
 
46
- await Promise.all(targets.map(async ({ contextKey, context }) => {
52
+ if (targets.length === 0) {
53
+ const defaultUserOpenId = this.options.defaultUserOpenId?.trim();
54
+ if (!defaultUserOpenId) return;
55
+ targets = [{ contextKey: `open_id:${defaultUserOpenId}` }];
56
+ }
57
+
58
+ const deliveries = await Promise.all(targets.map(async ({ contextKey, context }) => {
47
59
  let session = context?.currentSessionId ? this.store.getSession(context.currentSessionId) : undefined;
48
60
  if (session && this.metadataHydrator) {
49
61
  try {
@@ -81,12 +93,17 @@ export class StartupNotifier {
81
93
  });
82
94
  try {
83
95
  await this.outbound.sendInteractiveCard(contextKey, card);
96
+ return true;
84
97
  } catch (error) {
85
98
  this.logger.warn(
86
99
  { error, contextKey },
87
100
  "Failed to send startup status notification.",
88
101
  );
102
+ return false;
89
103
  }
90
104
  }));
105
+ if (deliveries.length > 0 && !deliveries.some(Boolean)) {
106
+ throw new Error("Failed to send any startup status notification.");
107
+ }
91
108
  }
92
109
  }
@@ -16,6 +16,6 @@ export async function startFeishu(
16
16
  ): Promise<void> {
17
17
  await prepare?.();
18
18
  await connector.start();
19
- onConnected?.();
20
19
  await notifier.notify(startedAt, restartReason);
20
+ onConnected?.();
21
21
  }
@@ -14,6 +14,7 @@ export class SafeRestartNotifier {
14
14
  private readonly messageIds = new Map<string, string>();
15
15
  private readonly cardHashes = new Map<string, string>();
16
16
  private activeScheduleId?: number;
17
+ private terminalScheduleId?: number;
17
18
  private pendingInitialStatus?: SafeRestartStatus;
18
19
  private initialCardTimer?: NodeJS.Timeout;
19
20
  private queue = Promise.resolve();
@@ -27,13 +28,24 @@ export class SafeRestartNotifier {
27
28
  ) {}
28
29
 
29
30
  update(status: SafeRestartStatus): Promise<void> {
31
+ if (this.terminalScheduleId !== undefined && status.scheduleId <= this.terminalScheduleId) {
32
+ if (status.phase !== "cancelled") return Promise.resolve();
33
+ }
34
+ if (this.activeScheduleId !== undefined && status.scheduleId < this.activeScheduleId) {
35
+ return Promise.resolve();
36
+ }
37
+ if (status.phase === "cancelled") {
38
+ this.terminalScheduleId = Math.max(this.terminalScheduleId ?? 0, status.scheduleId);
39
+ }
30
40
  if (this.activeScheduleId !== status.scheduleId) {
31
41
  this.activeScheduleId = status.scheduleId;
32
42
  this.messageIds.clear();
33
43
  this.cardHashes.clear();
34
44
  this.clearInitialCardTimer();
35
45
  this.pendingInitialStatus = status;
36
- const delayMs = this.options.initialCardDelayMs ?? DEFAULT_INITIAL_CARD_DELAY_MS;
46
+ const delayMs = status.phase === "cancelled"
47
+ ? 0
48
+ : this.options.initialCardDelayMs ?? DEFAULT_INITIAL_CARD_DELAY_MS;
37
49
  if (delayMs <= 0) return this.publishPendingInitialStatus();
38
50
  this.initialCardTimer = setTimeout(() => {
39
51
  this.initialCardTimer = undefined;
@@ -79,13 +91,16 @@ export class SafeRestartNotifier {
79
91
  private async publish(status: SafeRestartStatus): Promise<void> {
80
92
  const targets = this.store.listChatContexts("p2p");
81
93
  if (targets.length === 0) return;
82
- const waitingTasks = this.store.listAllSessions()
83
- .filter((session) => session.status === "running" || this.store.countQueuedPrompts(session.localSessionId) > 0)
84
- .map((session) => ({
85
- id: session.remoteSessionId ?? session.localSessionId,
86
- title: session.title,
87
- }));
94
+ const waitingTasks = status.phase === "cancelled"
95
+ ? []
96
+ : this.store.listAllSessions()
97
+ .filter((session) => session.status === "running" || this.store.countQueuedPrompts(session.localSessionId) > 0)
98
+ .map((session) => ({
99
+ id: session.remoteSessionId ?? session.localSessionId,
100
+ title: session.title,
101
+ }));
88
102
  const card = this.renderer.renderSafeRestartStatus({
103
+ scheduleId: status.scheduleId,
89
104
  reason: status.reason,
90
105
  phase: status.phase,
91
106
  remainingMs: status.remainingMs,