@keyou007/agent-bot 0.1.1 → 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 (73) hide show
  1. package/.env.example +3 -1
  2. package/CHANGELOG.md +15 -0
  3. package/README.md +23 -6
  4. package/README.zh.md +23 -6
  5. package/config.example.yaml +1 -2
  6. package/dist/cli/FeishuAppRegistration.js +12 -2
  7. package/dist/cli/FeishuAppRegistration.js.map +1 -1
  8. package/dist/cli/Initializer.js +164 -14
  9. package/dist/cli/Initializer.js.map +1 -1
  10. package/dist/cli/LocalControlClient.js +14 -1
  11. package/dist/cli/LocalControlClient.js.map +1 -1
  12. package/dist/cli/ServerStarter.js +69 -0
  13. package/dist/cli/ServerStarter.js.map +1 -0
  14. package/dist/cli/help.js +6 -2
  15. package/dist/cli/help.js.map +1 -1
  16. package/dist/cli/profile.js +40 -0
  17. package/dist/cli/profile.js.map +1 -0
  18. package/dist/cli.js +93 -89
  19. package/dist/cli.js.map +1 -1
  20. package/dist/config/loadConfig.js +7 -4
  21. package/dist/config/loadConfig.js.map +1 -1
  22. package/dist/config/paths.js +1 -0
  23. package/dist/config/paths.js.map +1 -1
  24. package/dist/config/schema.js +2 -1
  25. package/dist/config/schema.js.map +1 -1
  26. package/dist/feishu/CardRenderer.js +21 -3
  27. package/dist/feishu/CardRenderer.js.map +1 -1
  28. package/dist/feishu/FeishuConnector.js +8 -11
  29. package/dist/feishu/FeishuConnector.js.map +1 -1
  30. package/dist/feishu/transport.js +4 -11
  31. package/dist/feishu/transport.js.map +1 -1
  32. package/dist/index.js +20 -8
  33. package/dist/index.js.map +1 -1
  34. package/dist/proxy/ProxySessionController.js +18 -3
  35. package/dist/proxy/ProxySessionController.js.map +1 -1
  36. package/dist/startup/StartupNotifier.js +13 -2
  37. package/dist/startup/StartupNotifier.js.map +1 -1
  38. package/dist/startup/startFeishu.js +2 -1
  39. package/dist/startup/startFeishu.js.map +1 -1
  40. package/dist/supervision/SafeRestartNotifier.js +23 -7
  41. package/dist/supervision/SafeRestartNotifier.js.map +1 -1
  42. package/dist/supervision/SafeRestartScheduler.js +20 -2
  43. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  44. package/dist/supervision/SupervisorDiagnostics.js +209 -0
  45. package/dist/supervision/SupervisorDiagnostics.js.map +1 -0
  46. package/dist/supervisor.js +54 -16
  47. package/dist/supervisor.js.map +1 -1
  48. package/docs/technical-reference.md +28 -14
  49. package/docs/technical-reference.zh.md +28 -14
  50. package/npm-shrinkwrap.json +2 -2
  51. package/package.json +1 -1
  52. package/skills/agent-bot/SKILL.md +24 -4
  53. package/src/cli/FeishuAppRegistration.ts +14 -1
  54. package/src/cli/Initializer.ts +174 -14
  55. package/src/cli/LocalControlClient.ts +12 -1
  56. package/src/cli/ServerStarter.ts +106 -0
  57. package/src/cli/help.ts +6 -2
  58. package/src/cli/profile.ts +59 -0
  59. package/src/cli.ts +114 -84
  60. package/src/config/loadConfig.ts +12 -4
  61. package/src/config/paths.ts +1 -0
  62. package/src/config/schema.ts +2 -1
  63. package/src/feishu/CardRenderer.ts +25 -3
  64. package/src/feishu/FeishuConnector.ts +8 -12
  65. package/src/feishu/transport.ts +7 -16
  66. package/src/index.ts +31 -8
  67. package/src/proxy/ProxySessionController.ts +18 -3
  68. package/src/startup/StartupNotifier.ts +21 -4
  69. package/src/startup/startFeishu.ts +2 -0
  70. package/src/supervision/SafeRestartNotifier.ts +22 -7
  71. package/src/supervision/SafeRestartScheduler.ts +27 -3
  72. package/src/supervision/SupervisorDiagnostics.ts +268 -0
  73. package/src/supervisor.ts +53 -15
@@ -1,24 +1,15 @@
1
1
  import type { AppConfig } from "../config/schema.js";
2
2
 
3
- export type FeishuTransportMode = "sdk" | "console";
4
-
5
3
  type FeishuTransportConfig = Pick<
6
4
  AppConfig["feishu"],
7
- "transport" | "appId" | "appSecret" | "useConsoleWhenMissingCredentials"
5
+ "appId" | "appSecret"
8
6
  >;
9
7
 
10
- export function resolveFeishuTransport(config: FeishuTransportConfig): FeishuTransportMode {
11
- if (config.transport === "console") {
12
- return "console";
13
- }
14
-
15
- const hasCredentials = Boolean(config.appId && config.appSecret);
16
- if (config.transport === "sdk" || !config.useConsoleWhenMissingCredentials) {
17
- if (!hasCredentials) {
18
- throw new Error("Feishu appId/appSecret are required.");
19
- }
20
- return "sdk";
8
+ export function requireServerFeishuTransport(config: FeishuTransportConfig): "sdk" {
9
+ if (!config.appId || !config.appSecret) {
10
+ throw new Error(
11
+ "飞书机器人尚未配置。请先运行 agent-bot init 完成初始化;仅需本地调试时请使用 agent-bot console。",
12
+ );
21
13
  }
22
-
23
- return hasCredentials ? "sdk" : "console";
14
+ return "sdk";
24
15
  }
package/src/index.ts CHANGED
@@ -15,7 +15,7 @@ import { ConsoleFeishuClient } from "./feishu/ConsoleFeishuClient.js";
15
15
  import { FeishuConnector } from "./feishu/FeishuConnector.js";
16
16
  import { FeishuMessageClient } from "./feishu/FeishuMessageClient.js";
17
17
  import { FeishuTurnPresenter } from "./feishu/FeishuTurnPresenter.js";
18
- import { resolveFeishuTransport } from "./feishu/transport.js";
18
+ import { requireServerFeishuTransport } from "./feishu/transport.js";
19
19
  import { createLogger } from "./logging/logger.js";
20
20
  import { OutboundRouter, type OutboundRoute } from "./presentation/OutboundRouter.js";
21
21
  import { ProxySessionController } from "./proxy/ProxySessionController.js";
@@ -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";
@@ -36,6 +41,7 @@ const startupReason = process.env.AGENT_BOT_RESTART_REASON?.trim()
36
41
  || (supervised ? "Supervisor 启动" : "直接启动");
37
42
  const consoleOnly = process.env.AGENT_BOT_CONSOLE_ONLY === "1";
38
43
  const config = loadConfig();
44
+ const transport = consoleOnly ? "console" : requireServerFeishuTransport(config.feishu);
39
45
  const logger = createLogger(config);
40
46
  const store = new StateStore(config.storage.sqlitePath);
41
47
 
@@ -52,13 +58,13 @@ const codexProcessManager = new CodexProcessManager(
52
58
  const codexRuntime = new CodexRuntime(codexProcessManager, logger);
53
59
  const runtimes = new AgentRuntimeRegistry({ acp: acpRuntime, codex: codexRuntime });
54
60
 
55
- const transport = consoleOnly ? "console" : resolveFeishuTransport(config.feishu);
56
61
  const routes: OutboundRoute[] = [];
57
62
  let feishuConnector: FeishuConnector | undefined;
58
63
  let consoleConnector: ConsoleConnector | undefined;
59
64
  let startupNotifier: StartupNotifier | undefined;
60
65
  let safeRestartNotifier: SafeRestartNotifier | undefined;
61
66
  let controlServer: LocalControlServer | undefined;
67
+ let serverReady = false;
62
68
 
63
69
  const feishuOutbound = transport === "sdk" ? new FeishuMessageClient(config, logger) : undefined;
64
70
  if (feishuOutbound) {
@@ -77,6 +83,7 @@ if (feishuOutbound) {
77
83
  defaultAgentTitle: defaultAgent?.title ?? defaultAgentName,
78
84
  cwd: path.resolve(config.defaults.cwd),
79
85
  workspaceKind: defaultAgent?.kind === "codex" ? "projectless" : "project",
86
+ defaultUserOpenId: config.feishu.userOpenId,
80
87
  }, metadataHydrator);
81
88
  safeRestartNotifier = new SafeRestartNotifier(store, feishuOutbound, renderer, logger);
82
89
  }
@@ -95,16 +102,17 @@ if (routes.length === 0) throw new Error("Neither Feishu nor console input is en
95
102
  const outbound = new OutboundRouter(routes);
96
103
  let shuttingDown = false;
97
104
  let restartRequested = false;
98
- const controller = new ProxySessionController(config, store, runtimes, outbound, logger, {
99
- supervised,
100
- restart: requestRestart,
101
- });
102
105
  const safeRestart = new SafeRestartScheduler({
103
106
  readActivity: () => store.getServerActivityState(),
104
107
  onReady: (reason) => initiateRestart(reason),
105
108
  onStatus: (status) => safeRestartNotifier?.update(status),
106
109
  onStatusError: (error) => logger.warn({ error }, "Failed to publish safe restart status."),
107
110
  });
111
+ const controller = new ProxySessionController(config, store, runtimes, outbound, logger, {
112
+ supervised,
113
+ restart: requestRestart,
114
+ cancelSafeRestart: (scheduleId) => safeRestart.cancelScheduled(scheduleId),
115
+ });
108
116
 
109
117
  if (feishuOutbound) feishuConnector = new FeishuConnector(config, controller, logger);
110
118
  if (consoleEnabled) consoleConnector = new ConsoleConnector(controller, logger);
@@ -123,10 +131,18 @@ const startControlServer = async (): Promise<void> => {
123
131
  };
124
132
 
125
133
  if (feishuConnector && startupNotifier) {
126
- await startFeishu(feishuConnector, startupNotifier, processStartedAt, startupReason, startControlServer);
134
+ await startFeishu(
135
+ feishuConnector,
136
+ startupNotifier,
137
+ processStartedAt,
138
+ startupReason,
139
+ startControlServer,
140
+ () => { serverReady = true; },
141
+ );
127
142
  } else {
128
143
  await startControlServer();
129
144
  await feishuConnector?.start();
145
+ serverReady = true;
130
146
  }
131
147
  consoleConnector?.start();
132
148
 
@@ -156,6 +172,8 @@ async function handleControlRequest(request: ControlRequest): Promise<ControlRes
156
172
  return {
157
173
  ok: true,
158
174
  data: {
175
+ ready: serverReady,
176
+ phase: serverReady ? "ready" : "connecting_feishu",
159
177
  pid: process.pid,
160
178
  startedAt: processStartedAt.toISOString(),
161
179
  supervised,
@@ -213,7 +231,12 @@ async function shutdown(exitCode: number, restartReason?: string): Promise<void>
213
231
 
214
232
  function startReplacementSupervisor(restartReason: string): void {
215
233
  const supervisorEntry = fileURLToPath(new URL("./supervisor.js", import.meta.url));
216
- 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
+ ], {
217
240
  cwd: process.cwd(),
218
241
  detached: true,
219
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
  }
@@ -12,8 +12,10 @@ export async function startFeishu(
12
12
  startedAt: Date,
13
13
  restartReason: string,
14
14
  prepare?: () => Promise<void>,
15
+ onConnected?: () => void,
15
16
  ): Promise<void> {
16
17
  await prepare?.();
17
18
  await connector.start();
18
19
  await notifier.notify(startedAt, restartReason);
20
+ onConnected?.();
19
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
 
@@ -0,0 +1,268 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import type { AppConfig } from "../config/schema.js";
4
+
5
+ const MAX_LOG_BYTES = 10 * 1024 * 1024;
6
+ const LOG_BACKUP_COUNT = 3;
7
+
8
+ export interface SupervisorDiagnosticsPaths {
9
+ supervisorLogPath: string;
10
+ workerStderrPath: string;
11
+ crashReportDirectory: string;
12
+ lastCrashPath: string;
13
+ }
14
+
15
+ export interface WorkerCrashContext {
16
+ workerPid?: number;
17
+ exitCode: number | null;
18
+ signal: NodeJS.Signals | null;
19
+ startedAt: string;
20
+ exitedAt: string;
21
+ uptimeMs: number;
22
+ consecutiveFailures: number;
23
+ restartDelayMs: number;
24
+ }
25
+
26
+ export interface WorkerCrashRecord extends WorkerCrashContext {
27
+ version: 1;
28
+ supervisorPid: number;
29
+ nodeVersion: string;
30
+ executablePath: string;
31
+ cwd: string;
32
+ supervisorLogPath: string;
33
+ workerStderrPath: string;
34
+ crashReportDirectory: string;
35
+ diagnosticReports: string[];
36
+ }
37
+
38
+ type ExtendedProcessReport = typeof process.report & {
39
+ excludeEnv?: boolean;
40
+ excludeNetwork?: boolean;
41
+ };
42
+
43
+ export class SupervisorDiagnostics {
44
+ readonly paths: SupervisorDiagnosticsPaths;
45
+
46
+ constructor(config: Pick<AppConfig, "logging" | "storage">) {
47
+ this.paths = resolveSupervisorDiagnosticsPaths(config);
48
+ }
49
+
50
+ initialize(): void {
51
+ try {
52
+ ensurePrivateDirectory(path.dirname(this.paths.supervisorLogPath));
53
+ ensurePrivateDirectory(this.paths.crashReportDirectory);
54
+ rotateLogFile(this.paths.supervisorLogPath);
55
+ rotateLogFile(this.paths.workerStderrPath);
56
+ this.configureCurrentProcessReports();
57
+ this.writeEvent("diagnostics_initialized", {
58
+ nodeVersion: process.versions.node,
59
+ supervisorLogPath: this.paths.supervisorLogPath,
60
+ workerStderrPath: this.paths.workerStderrPath,
61
+ crashReportDirectory: this.paths.crashReportDirectory,
62
+ });
63
+ } catch (error) {
64
+ writeFallbackDiagnostic("initialize_failed", error);
65
+ }
66
+ }
67
+
68
+ writeEvent(event: string, data: Record<string, unknown> = {}): void {
69
+ const line = `${JSON.stringify({
70
+ component: "agent-bot-supervisor",
71
+ event,
72
+ time: new Date().toISOString(),
73
+ ...data,
74
+ })}\n`;
75
+ try {
76
+ fs.appendFileSync(this.paths.supervisorLogPath, line, { encoding: "utf8", mode: 0o600 });
77
+ restrictFilePermissions(this.paths.supervisorLogPath);
78
+ } catch (error) {
79
+ writeFallbackDiagnostic(event, error);
80
+ }
81
+ }
82
+
83
+ openWorkerStderr(): number | "ignore" {
84
+ try {
85
+ rotateLogFile(this.paths.workerStderrPath);
86
+ fs.appendFileSync(
87
+ this.paths.workerStderrPath,
88
+ `${JSON.stringify({ event: "worker_started", time: new Date().toISOString() })}\n`,
89
+ { encoding: "utf8", mode: 0o600 },
90
+ );
91
+ restrictFilePermissions(this.paths.workerStderrPath);
92
+ return fs.openSync(this.paths.workerStderrPath, "a", 0o600);
93
+ } catch (error) {
94
+ this.writeEvent("worker_stderr_open_failed", { error: errorMessage(error) });
95
+ return "ignore";
96
+ }
97
+ }
98
+
99
+ closeWorkerStderr(target: number | "ignore"): void {
100
+ if (typeof target !== "number") return;
101
+ try {
102
+ fs.closeSync(target);
103
+ } catch (error) {
104
+ this.writeEvent("worker_stderr_close_failed", { error: errorMessage(error) });
105
+ }
106
+ }
107
+
108
+ recordCrash(context: WorkerCrashContext): WorkerCrashRecord | undefined {
109
+ try {
110
+ const record: WorkerCrashRecord = {
111
+ version: 1,
112
+ ...context,
113
+ supervisorPid: process.pid,
114
+ nodeVersion: process.versions.node,
115
+ executablePath: process.execPath,
116
+ cwd: process.cwd(),
117
+ supervisorLogPath: this.paths.supervisorLogPath,
118
+ workerStderrPath: this.paths.workerStderrPath,
119
+ crashReportDirectory: this.paths.crashReportDirectory,
120
+ diagnosticReports: findDiagnosticReports(
121
+ this.paths.crashReportDirectory,
122
+ context.workerPid,
123
+ Date.parse(context.startedAt),
124
+ ),
125
+ };
126
+ const crashPath = path.join(
127
+ this.paths.crashReportDirectory,
128
+ crashRecordFilename(context.exitedAt, context.workerPid),
129
+ );
130
+ writePrivateJson(crashPath, record);
131
+ writePrivateJson(this.paths.lastCrashPath, record);
132
+ this.writeEvent("crash_context_saved", {
133
+ crashPath,
134
+ lastCrashPath: this.paths.lastCrashPath,
135
+ diagnosticReports: record.diagnosticReports,
136
+ });
137
+ return record;
138
+ } catch (error) {
139
+ this.writeEvent("crash_context_save_failed", { error: errorMessage(error) });
140
+ return undefined;
141
+ }
142
+ }
143
+
144
+ private configureCurrentProcessReports(): void {
145
+ const report = process.report as ExtendedProcessReport;
146
+ report.directory = this.paths.crashReportDirectory;
147
+ report.reportOnFatalError = true;
148
+ report.reportOnUncaughtException = true;
149
+ if ("excludeEnv" in report) report.excludeEnv = true;
150
+ if ("excludeNetwork" in report) report.excludeNetwork = true;
151
+ }
152
+ }
153
+
154
+ export function resolveSupervisorDiagnosticsPaths(
155
+ config: Pick<AppConfig, "logging" | "storage">,
156
+ ): SupervisorDiagnosticsPaths {
157
+ const logDirectory = path.dirname(config.logging.path);
158
+ const dataDirectory = path.dirname(config.storage.sqlitePath);
159
+ return {
160
+ supervisorLogPath: path.join(logDirectory, "supervisor.log"),
161
+ workerStderrPath: path.join(logDirectory, "worker.stderr.log"),
162
+ crashReportDirectory: path.join(dataDirectory, "crash-reports"),
163
+ lastCrashPath: path.join(dataDirectory, "last-crash.json"),
164
+ };
165
+ }
166
+
167
+ export function nodeDiagnosticReportArguments(
168
+ reportDirectory: string,
169
+ allowedFlags: ReadonlySet<string> = process.allowedNodeEnvironmentFlags,
170
+ ): string[] {
171
+ const args = [
172
+ "--report-on-fatalerror",
173
+ "--report-uncaught-exception",
174
+ `--report-directory=${reportDirectory}`,
175
+ ];
176
+ if (allowedFlags.has("--report-exclude-env")) args.push("--report-exclude-env");
177
+ if (allowedFlags.has("--report-exclude-network")) args.push("--report-exclude-network");
178
+ return args;
179
+ }
180
+
181
+ export function prepareCrashReportDirectory(reportDirectory: string): boolean {
182
+ try {
183
+ ensurePrivateDirectory(reportDirectory);
184
+ return true;
185
+ } catch {
186
+ return false;
187
+ }
188
+ }
189
+
190
+ function ensurePrivateDirectory(directoryPath: string): void {
191
+ fs.mkdirSync(directoryPath, { recursive: true, mode: 0o700 });
192
+ try {
193
+ fs.chmodSync(directoryPath, 0o700);
194
+ } catch {
195
+ // Windows and some mounted filesystems do not implement POSIX modes.
196
+ }
197
+ }
198
+
199
+ function rotateLogFile(filePath: string): void {
200
+ const size = fs.statSync(filePath, { throwIfNoEntry: false })?.size ?? 0;
201
+ if (size < MAX_LOG_BYTES) return;
202
+ for (let index = LOG_BACKUP_COUNT; index >= 1; index -= 1) {
203
+ const source = index === 1 ? filePath : `${filePath}.${index - 1}`;
204
+ const target = `${filePath}.${index}`;
205
+ if (!fs.existsSync(source)) continue;
206
+ fs.rmSync(target, { force: true });
207
+ fs.renameSync(source, target);
208
+ }
209
+ }
210
+
211
+ function findDiagnosticReports(
212
+ reportDirectory: string,
213
+ workerPid: number | undefined,
214
+ startedAtMs: number,
215
+ ): string[] {
216
+ if (!fs.existsSync(reportDirectory)) return [];
217
+ const pidToken = workerPid === undefined ? undefined : `.${workerPid}.`;
218
+ return fs.readdirSync(reportDirectory, { withFileTypes: true })
219
+ .filter((entry) => entry.isFile() && /^report\..+\.json$/i.test(entry.name))
220
+ .filter((entry) => {
221
+ if (pidToken && entry.name.includes(pidToken)) return true;
222
+ const modifiedAt = fs.statSync(path.join(reportDirectory, entry.name)).mtimeMs;
223
+ return workerPid === undefined && modifiedAt >= startedAtMs - 1_000;
224
+ })
225
+ .map((entry) => path.join(reportDirectory, entry.name))
226
+ .sort();
227
+ }
228
+
229
+ function crashRecordFilename(exitedAt: string, workerPid: number | undefined): string {
230
+ const timestamp = exitedAt.replace(/\D/g, "");
231
+ return `crash-${timestamp}-${workerPid ?? "unknown"}.json`;
232
+ }
233
+
234
+ function writePrivateJson(filePath: string, value: unknown): void {
235
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
236
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, {
237
+ encoding: "utf8",
238
+ mode: 0o600,
239
+ });
240
+ restrictFilePermissions(filePath);
241
+ }
242
+
243
+ function restrictFilePermissions(filePath: string): void {
244
+ try {
245
+ fs.chmodSync(filePath, 0o600);
246
+ } catch {
247
+ // Windows and some mounted filesystems do not implement POSIX modes.
248
+ }
249
+ }
250
+
251
+ function writeFallbackDiagnostic(event: string, error: unknown): void {
252
+ try {
253
+ process.stderr.write(
254
+ `${JSON.stringify({
255
+ component: "agent-bot-supervisor",
256
+ event,
257
+ time: new Date().toISOString(),
258
+ error: errorMessage(error),
259
+ })}\n`,
260
+ );
261
+ } catch {
262
+ // Diagnostics must never prevent the supervisor from keeping the worker alive.
263
+ }
264
+ }
265
+
266
+ function errorMessage(error: unknown): string {
267
+ return error instanceof Error ? error.message : String(error);
268
+ }