@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
@@ -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
+ }
package/src/supervisor.ts CHANGED
@@ -12,12 +12,15 @@ import {
12
12
  STABLE_UPTIME_MS,
13
13
  STOP_EXIT_CODE,
14
14
  } from "./supervision/restartPolicy.js";
15
+ import { SupervisorDiagnostics, nodeDiagnosticReportArguments } from "./supervision/SupervisorDiagnostics.js";
15
16
 
16
17
  const childEntry = fileURLToPath(new URL("./index.js", import.meta.url));
17
18
  const config = loadConfig();
18
19
  requireServerFeishuTransport(config.feishu);
19
20
  const sqlitePath = config.storage.sqlitePath;
20
21
  const serverEndpoint = controlEndpoint(sqlitePath);
22
+ const diagnostics = new SupervisorDiagnostics(config);
23
+ diagnostics.initialize();
21
24
  let child: ChildProcess | undefined;
22
25
  let restartTimer: NodeJS.Timeout | undefined;
23
26
  let stopping = false;
@@ -34,19 +37,33 @@ async function startChild(): Promise<void> {
34
37
  const startedAt = Date.now();
35
38
  const restartReason = nextStartReason;
36
39
  nextStartReason = "Supervisor 重新拉起进程";
37
- child = spawn(process.execPath, [childEntry], {
38
- cwd: process.cwd(),
39
- env: { ...process.env, AGENT_BOT_SUPERVISED: "1", AGENT_BOT_RESTART_REASON: restartReason },
40
- stdio: "inherit",
41
- windowsHide: true,
40
+ const workerStderr = diagnostics.openWorkerStderr();
41
+ try {
42
+ child = spawn(process.execPath, [
43
+ ...nodeDiagnosticReportArguments(diagnostics.paths.crashReportDirectory),
44
+ childEntry,
45
+ ], {
46
+ cwd: process.cwd(),
47
+ env: { ...process.env, AGENT_BOT_SUPERVISED: "1", AGENT_BOT_RESTART_REASON: restartReason },
48
+ stdio: ["ignore", "ignore", workerStderr],
49
+ windowsHide: true,
50
+ });
51
+ } finally {
52
+ diagnostics.closeWorkerStderr(workerStderr);
53
+ }
54
+ const workerPid = child.pid;
55
+ writeSupervisorLog("started", {
56
+ pid: child.pid,
57
+ restartReason,
58
+ workerStderrPath: diagnostics.paths.workerStderrPath,
59
+ crashReportDirectory: diagnostics.paths.crashReportDirectory,
42
60
  });
43
- writeSupervisorLog("started", { pid: child.pid, restartReason });
44
61
 
45
62
  child.once("error", (error) => {
46
- writeSupervisorLog("spawn_error", { error: error.message });
63
+ writeSupervisorLog("spawn_error", { pid: workerPid, error: error.message });
47
64
  });
48
65
  child.once("exit", (code, signal) => {
49
- void handleChildExit(code, signal, startedAt);
66
+ void handleChildExit(code, signal, startedAt, workerPid);
50
67
  });
51
68
  }
52
69
 
@@ -54,10 +71,11 @@ async function handleChildExit(
54
71
  code: number | null,
55
72
  signal: NodeJS.Signals | null,
56
73
  startedAt: number,
74
+ workerPid: number | undefined,
57
75
  ): Promise<void> {
58
76
  child = undefined;
59
77
  const uptimeMs = Date.now() - startedAt;
60
- writeSupervisorLog("exited", { code, signal, uptimeMs });
78
+ writeSupervisorLog("exited", { pid: workerPid, code, signal, uptimeMs });
61
79
  if (stopping) {
62
80
  process.exit(0);
63
81
  return;
@@ -80,7 +98,24 @@ async function handleChildExit(
80
98
  const delayMs = intentional
81
99
  ? INTENTIONAL_RESTART_DELAY_MS
82
100
  : crashRestartDelayMs(consecutiveFailures);
83
- writeSupervisorLog("restarting", { delayMs, consecutiveFailures, intentional });
101
+ if (!intentional) {
102
+ diagnostics.recordCrash({
103
+ workerPid,
104
+ exitCode: code,
105
+ signal,
106
+ startedAt: new Date(startedAt).toISOString(),
107
+ exitedAt: new Date().toISOString(),
108
+ uptimeMs,
109
+ consecutiveFailures,
110
+ restartDelayMs: delayMs,
111
+ });
112
+ }
113
+ writeSupervisorLog("restarting", {
114
+ previousPid: workerPid,
115
+ delayMs,
116
+ consecutiveFailures,
117
+ intentional,
118
+ });
84
119
  restartTimer = setTimeout(() => void startChild(), delayMs);
85
120
  }
86
121
 
@@ -96,7 +131,7 @@ function stop(signal: NodeJS.Signals): void {
96
131
  }
97
132
 
98
133
  function writeSupervisorLog(event: string, data: Record<string, unknown>): void {
99
- process.stdout.write(`${JSON.stringify({ component: "agent-bot-supervisor", event, time: new Date().toISOString(), ...data })}\n`);
134
+ diagnostics.writeEvent(event, data);
100
135
  }
101
136
 
102
137
  process.on("SIGINT", () => stop("SIGINT"));