@keyou007/agent-bot 0.1.2 → 0.1.4

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 (91) hide show
  1. package/.env.example +3 -1
  2. package/CHANGELOG.md +15 -0
  3. package/README.md +23 -8
  4. package/README.zh.md +23 -8
  5. package/config.example.yaml +1 -0
  6. package/dist/cli/FeishuAppConfiguration.js +10 -10
  7. package/dist/cli/FeishuAppConfiguration.js.map +1 -1
  8. package/dist/cli/FeishuAppRegistration.js +21 -11
  9. package/dist/cli/FeishuAppRegistration.js.map +1 -1
  10. package/dist/cli/Initializer.js +14 -8
  11. package/dist/cli/Initializer.js.map +1 -1
  12. package/dist/cli/LocalControlClient.js +2 -2
  13. package/dist/cli/LocalControlClient.js.map +1 -1
  14. package/dist/cli/LocalControlServer.js +1 -1
  15. package/dist/cli/LocalControlServer.js.map +1 -1
  16. package/dist/cli/ServerStarter.js +69 -0
  17. package/dist/cli/ServerStarter.js.map +1 -0
  18. package/dist/cli/SkillRegistry.js +4 -4
  19. package/dist/cli/SkillRegistry.js.map +1 -1
  20. package/dist/cli/help.js +5 -56
  21. package/dist/cli/help.js.map +1 -1
  22. package/dist/cli/packageVersion.js +1 -1
  23. package/dist/cli/packageVersion.js.map +1 -1
  24. package/dist/cli/profile.js +40 -0
  25. package/dist/cli/profile.js.map +1 -0
  26. package/dist/cli/serverStatus.js +37 -0
  27. package/dist/cli/serverStatus.js.map +1 -0
  28. package/dist/cli/taskChatRoute.js +2 -2
  29. package/dist/cli/taskChatRoute.js.map +1 -1
  30. package/dist/cli.js +148 -168
  31. package/dist/cli.js.map +1 -1
  32. package/dist/config/loadConfig.js +7 -2
  33. package/dist/config/loadConfig.js.map +1 -1
  34. package/dist/config/paths.js +1 -0
  35. package/dist/config/paths.js.map +1 -1
  36. package/dist/config/schema.js +1 -0
  37. package/dist/config/schema.js.map +1 -1
  38. package/dist/feishu/CardRenderer.js +21 -3
  39. package/dist/feishu/CardRenderer.js.map +1 -1
  40. package/dist/feishu/FeishuConnector.js +7 -76
  41. package/dist/feishu/FeishuConnector.js.map +1 -1
  42. package/dist/feishu/transport.js +1 -1
  43. package/dist/feishu/transport.js.map +1 -1
  44. package/dist/index.js +18 -9
  45. package/dist/index.js.map +1 -1
  46. package/dist/proxy/ProxySessionController.js +28 -13
  47. package/dist/proxy/ProxySessionController.js.map +1 -1
  48. package/dist/startup/StartupNotifier.js +13 -2
  49. package/dist/startup/StartupNotifier.js.map +1 -1
  50. package/dist/startup/startFeishu.js +1 -1
  51. package/dist/startup/startFeishu.js.map +1 -1
  52. package/dist/supervision/SafeRestartNotifier.js +23 -7
  53. package/dist/supervision/SafeRestartNotifier.js.map +1 -1
  54. package/dist/supervision/SafeRestartScheduler.js +20 -2
  55. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  56. package/dist/supervision/SupervisorDiagnostics.js +209 -0
  57. package/dist/supervision/SupervisorDiagnostics.js.map +1 -0
  58. package/dist/supervisor.js +47 -12
  59. package/dist/supervisor.js.map +1 -1
  60. package/docs/technical-reference.md +25 -5
  61. package/docs/technical-reference.zh.md +25 -5
  62. package/npm-shrinkwrap.json +2 -2
  63. package/package.json +1 -1
  64. package/skills/agent-bot/SKILL.md +24 -4
  65. package/src/cli/FeishuAppConfiguration.ts +10 -10
  66. package/src/cli/FeishuAppRegistration.ts +23 -10
  67. package/src/cli/Initializer.ts +14 -7
  68. package/src/cli/LocalControlClient.ts +2 -2
  69. package/src/cli/LocalControlServer.ts +1 -1
  70. package/src/cli/ServerStarter.ts +106 -0
  71. package/src/cli/SkillRegistry.ts +4 -4
  72. package/src/cli/help.ts +5 -65
  73. package/src/cli/packageVersion.ts +1 -1
  74. package/src/cli/profile.ts +59 -0
  75. package/src/cli/serverStatus.ts +39 -0
  76. package/src/cli/taskChatRoute.ts +2 -2
  77. package/src/cli.ts +160 -167
  78. package/src/config/loadConfig.ts +12 -2
  79. package/src/config/paths.ts +1 -0
  80. package/src/config/schema.ts +1 -0
  81. package/src/feishu/CardRenderer.ts +25 -3
  82. package/src/feishu/FeishuConnector.ts +6 -87
  83. package/src/feishu/transport.ts +1 -1
  84. package/src/index.ts +22 -9
  85. package/src/proxy/ProxySessionController.ts +28 -13
  86. package/src/startup/StartupNotifier.ts +21 -4
  87. package/src/startup/startFeishu.ts +1 -1
  88. package/src/supervision/SafeRestartNotifier.ts +22 -7
  89. package/src/supervision/SafeRestartScheduler.ts +27 -3
  90. package/src/supervision/SupervisorDiagnostics.ts +268 -0
  91. package/src/supervisor.ts +46 -11
@@ -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
 
@@ -8,7 +8,7 @@ type FeishuTransportConfig = Pick<
8
8
  export function requireServerFeishuTransport(config: FeishuTransportConfig): "sdk" {
9
9
  if (!config.appId || !config.appSecret) {
10
10
  throw new Error(
11
- "飞书机器人尚未配置。请先运行 agent-bot init 完成初始化;仅需本地调试时请使用 agent-bot console。",
11
+ "The Lark bot is not configured. Run agent-bot init first, or use agent-bot console for local-only testing.",
12
12
  );
13
13
  }
14
14
  return "sdk";
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);
@@ -170,6 +177,7 @@ async function handleControlRequest(request: ControlRequest): Promise<ControlRes
170
177
  pid: process.pid,
171
178
  startedAt: processStartedAt.toISOString(),
172
179
  supervised,
180
+ feishuAppId: config.feishu.appId ?? null,
173
181
  safeRestartScheduled: safeRestart.scheduled,
174
182
  safeRestartReason: safeRestart.pendingReason,
175
183
  activity: store.getServerActivityState(),
@@ -181,16 +189,16 @@ async function handleControlRequest(request: ControlRequest): Promise<ControlRes
181
189
  return {
182
190
  ok: true,
183
191
  message: newlyScheduled
184
- ? "已安排安全重启;将在全部任务完成并持续空闲 15 秒后执行。"
185
- : "安全重启已在等待中,已更新重启原因。",
192
+ ? "Safe restart scheduled. It will run after all tasks finish and the server stays idle for 15 seconds."
193
+ : "A safe restart is already pending. Its reason has been updated.",
186
194
  };
187
195
  }
188
196
  setTimeout(() => void initiateRestart(request.reason), 25);
189
- return { ok: true, message: "已请求立即重启。" };
197
+ return { ok: true, message: "Immediate restart requested." };
190
198
  case "server_stop":
191
199
  safeRestart.cancel();
192
200
  setTimeout(() => void shutdown(STOP_EXIT_CODE), 25);
193
- return { ok: true, message: "已请求停止 Agent Bot server。" };
201
+ return { ok: true, message: "Agent Bot server stop requested." };
194
202
  case "task_status":
195
203
  return { ok: true, data: await controller.controlGetTaskStatus(request.localSessionId) };
196
204
  case "task_stop":
@@ -224,7 +232,12 @@ async function shutdown(exitCode: number, restartReason?: string): Promise<void>
224
232
 
225
233
  function startReplacementSupervisor(restartReason: string): void {
226
234
  const supervisorEntry = fileURLToPath(new URL("./supervisor.js", import.meta.url));
227
- const supervisor = spawn(process.execPath, [supervisorEntry], {
235
+ const reportDirectory = resolveSupervisorDiagnosticsPaths(config).crashReportDirectory;
236
+ prepareCrashReportDirectory(reportDirectory);
237
+ const supervisor = spawn(process.execPath, [
238
+ ...nodeDiagnosticReportArguments(reportDirectory),
239
+ supervisorEntry,
240
+ ], {
228
241
  cwd: process.cwd(),
229
242
  detached: true,
230
243
  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") {
@@ -400,9 +415,9 @@ export class ProxySessionController {
400
415
 
401
416
  async controlStopTask(localSessionId: string): Promise<string> {
402
417
  const record = this.store.getSession(localSessionId);
403
- if (!record || record.status === "closed") throw new Error(`找不到任务:${localSessionId}`);
418
+ if (!record || record.status === "closed") throw new Error(`Task not found: ${localSessionId}`);
404
419
  await this.cancelSession(record);
405
- return `已请求停止任务:${record.title ?? record.remoteSessionId ?? record.localSessionId}`;
420
+ return `Task stop requested: ${record.title ?? record.remoteSessionId ?? record.localSessionId}`;
406
421
  }
407
422
 
408
423
  async controlGetTaskStatus(localSessionId: string): Promise<{
@@ -411,7 +426,7 @@ export class ProxySessionController {
411
426
  remote?: RemoteSessionSummary;
412
427
  }> {
413
428
  const record = this.store.getSession(localSessionId);
414
- if (!record) throw new Error(`找不到任务:${localSessionId}`);
429
+ if (!record) throw new Error(`Task not found: ${localSessionId}`);
415
430
  let remote: RemoteSessionSummary | undefined;
416
431
  if (record.remoteSessionId) {
417
432
  const runtime = this.runtimes.forAgent(this.ensureAgent(record.agentName));
@@ -432,9 +447,9 @@ export class ProxySessionController {
432
447
 
433
448
  async controlSetTaskTitle(localSessionId: string, title: string): Promise<string> {
434
449
  const normalizedTitle = normalizeTaskTitle(title);
435
- if (!normalizedTitle) throw new Error("任务标题不能为空。");
450
+ if (!normalizedTitle) throw new Error("The task title cannot be empty.");
436
451
  const record = this.store.getSession(localSessionId);
437
- if (!record || record.status === "closed") throw new Error(`找不到任务:${localSessionId}`);
452
+ if (!record || record.status === "closed") throw new Error(`Task not found: ${localSessionId}`);
438
453
  const loaded = await this.loadSession(record);
439
454
  if (loaded.runtime.setTitle) await loaded.runtime.setTitle(record.localSessionId, normalizedTitle);
440
455
  else loaded.session.title = normalizedTitle;
@@ -445,14 +460,14 @@ export class ProxySessionController {
445
460
  title: normalizedTitle,
446
461
  source: "cli",
447
462
  });
448
- return `任务标题已修改为:${normalizedTitle}`;
463
+ return `Task title changed to: ${normalizedTitle}`;
449
464
  }
450
465
 
451
466
  async controlSendTaskPrompt(localSessionId: string, text: string): Promise<string> {
452
467
  const promptText = text.trim();
453
- if (!promptText) throw new Error("Prompt 不能为空。");
468
+ if (!promptText) throw new Error("The Prompt cannot be empty.");
454
469
  const record = this.store.getSession(localSessionId);
455
- if (!record || record.status === "closed") throw new Error(`找不到任务:${localSessionId}`);
470
+ if (!record || record.status === "closed") throw new Error(`Task not found: ${localSessionId}`);
456
471
  const runtime = this.runtimes.forAgent(this.ensureAgent(record.agentName));
457
472
  const activeTurnId = runtime.getSession(localSessionId)?.activeTurnId;
458
473
  const routedContextKey = this.outbound.getSessionContextKey(localSessionId);
@@ -472,7 +487,7 @@ export class ProxySessionController {
472
487
  ?? lastSnapshot?.replyTarget
473
488
  ?? routedReplyTarget;
474
489
  if (isThreadContextKey(responseContextKey) && !existingReplyTarget) {
475
- throw new Error("无法确定目标任务的话题回复位置,未发送 Prompt。");
490
+ throw new Error("Could not resolve the target task's thread reply location. The Prompt was not sent.");
476
491
  }
477
492
  const scopedRecord = this.store.getSessionForContext(localSessionId, responseContextKey)
478
493
  ?? { ...record, contextKey: responseContextKey };
@@ -506,7 +521,7 @@ export class ProxySessionController {
506
521
  } finally {
507
522
  if (this.messageQueues.get(responseContextKey) === next) this.messageQueues.delete(responseContextKey);
508
523
  }
509
- return `已通过机器人向原会话发送 Prompt,并提交给任务:${record.title ?? record.remoteSessionId ?? record.localSessionId}`;
524
+ return `The Prompt was posted to the original chat and submitted to the task: ${record.title ?? record.remoteSessionId ?? record.localSessionId}`;
510
525
  }
511
526
 
512
527
  private cardActionContextKey(action: CardAction): string {
@@ -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,
@@ -4,7 +4,12 @@ export interface ServerActivityState {
4
4
  latestInboundAt?: string;
5
5
  }
6
6
 
7
- export type SafeRestartPhase = "waiting_tasks" | "waiting_delivery" | "countdown" | "restarting";
7
+ export type SafeRestartPhase =
8
+ | "waiting_tasks"
9
+ | "waiting_delivery"
10
+ | "countdown"
11
+ | "restarting"
12
+ | "cancelled";
8
13
 
9
14
  export interface SafeRestartStatus {
10
15
  scheduleId: number;
@@ -56,6 +61,24 @@ export class SafeRestartScheduler {
56
61
  }
57
62
 
58
63
  cancel(): void {
64
+ this.clear();
65
+ }
66
+
67
+ async cancelScheduled(expectedScheduleId: number): Promise<boolean> {
68
+ const reason = this.reason;
69
+ if (!reason || this.scheduleId !== expectedScheduleId) return false;
70
+ const activity = this.options.readActivity();
71
+ this.clear();
72
+ await this.emitStatus({
73
+ scheduleId: expectedScheduleId,
74
+ reason,
75
+ phase: "cancelled",
76
+ activity,
77
+ });
78
+ return true;
79
+ }
80
+
81
+ private clear(): void {
59
82
  if (this.timer) clearInterval(this.timer);
60
83
  this.timer = undefined;
61
84
  this.reason = undefined;
@@ -117,8 +140,9 @@ export class SafeRestartScheduler {
117
140
  }
118
141
  return;
119
142
  }
120
- await this.emitStatus({ scheduleId: this.scheduleId, reason, phase: "restarting", activity: confirmed, remainingMs: 0 });
121
- this.cancel();
143
+ const scheduleId = this.scheduleId;
144
+ this.clear();
145
+ await this.emitStatus({ scheduleId, reason, phase: "restarting", activity: confirmed, remainingMs: 0 });
122
146
  await this.options.onReady(reason);
123
147
  }
124
148