@keyou007/agent-bot 0.1.17-alpha.1 → 0.1.17

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 (45) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +3 -1
  3. package/README.zh.md +3 -1
  4. package/config.example.yaml +12 -0
  5. package/dist/config/loadConfig.js +10 -0
  6. package/dist/config/loadConfig.js.map +1 -1
  7. package/dist/config/schema.js +6 -0
  8. package/dist/config/schema.js.map +1 -1
  9. package/dist/feishu/CardRenderer.js +6 -16
  10. package/dist/feishu/CardRenderer.js.map +1 -1
  11. package/dist/feishu/FeishuMarkdown.js +42 -13
  12. package/dist/feishu/FeishuMarkdown.js.map +1 -1
  13. package/dist/feishu/FeishuTurnPresenter.js +33 -10
  14. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  15. package/dist/feishu/LocalImageMarkdown.js +16 -2
  16. package/dist/feishu/LocalImageMarkdown.js.map +1 -1
  17. package/dist/index.js +33 -5
  18. package/dist/index.js.map +1 -1
  19. package/dist/local-files/LocalFileViewerServer.js +1064 -0
  20. package/dist/local-files/LocalFileViewerServer.js.map +1 -0
  21. package/dist/local-files/NetworkAddressSelector.js +97 -0
  22. package/dist/local-files/NetworkAddressSelector.js.map +1 -0
  23. package/dist/proxy/ProxySessionController.js +81 -34
  24. package/dist/proxy/ProxySessionController.js.map +1 -1
  25. package/dist/startup/StartupNotifier.js +7 -4
  26. package/dist/startup/StartupNotifier.js.map +1 -1
  27. package/dist/supervision/SafeRestartScheduler.js +15 -2
  28. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  29. package/docs/technical-reference.md +10 -0
  30. package/docs/technical-reference.zh.md +10 -0
  31. package/npm-shrinkwrap.json +12 -2
  32. package/package.json +2 -1
  33. package/skills/agent-bot/SKILL.md +1 -1
  34. package/src/config/loadConfig.ts +10 -0
  35. package/src/config/schema.ts +9 -0
  36. package/src/feishu/CardRenderer.ts +6 -22
  37. package/src/feishu/FeishuMarkdown.ts +51 -12
  38. package/src/feishu/FeishuTurnPresenter.ts +41 -11
  39. package/src/feishu/LocalImageMarkdown.ts +23 -2
  40. package/src/index.ts +33 -4
  41. package/src/local-files/LocalFileViewerServer.ts +1142 -0
  42. package/src/local-files/NetworkAddressSelector.ts +111 -0
  43. package/src/proxy/ProxySessionController.ts +75 -24
  44. package/src/startup/StartupNotifier.ts +9 -4
  45. package/src/supervision/SafeRestartScheduler.ts +23 -2
@@ -0,0 +1,111 @@
1
+ import os from "node:os";
2
+
3
+ export type NetworkConnectionKind = "wired" | "wifi" | "other" | "vpn";
4
+
5
+ export interface PreferredNetworkAddress {
6
+ address: string;
7
+ interfaceName: string;
8
+ kind: NetworkConnectionKind;
9
+ }
10
+
11
+ type NetworkInterfaces = ReturnType<typeof os.networkInterfaces>;
12
+
13
+ const KIND_PRIORITY: Record<NetworkConnectionKind, number> = {
14
+ wired: 0,
15
+ wifi: 1,
16
+ other: 2,
17
+ vpn: 3,
18
+ };
19
+
20
+ const VIRTUAL_INTERFACE = /(?:loopback|hyper[- ]?v|vEthernet|vmware|virtualbox|docker|wsl|bluetooth|npcap|container|\bbridge\b|^br[-_]|^virbr|^veth|^awdl|^llw)/iu;
21
+ const VPN_INTERFACE = /(?:\bvpn\b|wireguard|tailscale|zerotier|hamachi|nordlynx|openvpn|anyconnect|fortinet|globalprotect|^utun\d*$|^tun\d*$|^tap\d*$|^wg\d*$|^ppp\d*$|^ipsec\d*$)/iu;
22
+ const WIFI_INTERFACE = /(?:wi-?fi|wireless|wlan|airport|无线|^wl[a-z0-9_-]*)/iu;
23
+ const WIRED_INTERFACE = /(?:ethernet|以太网|local area connection|本地连接|\blan\b|^(?:eth|enp|ens|eno|enx)[a-z0-9_-]*)/iu;
24
+
25
+ export function selectPreferredNetworkAddress(
26
+ interfaces: NetworkInterfaces = os.networkInterfaces(),
27
+ ): PreferredNetworkAddress | undefined {
28
+ const candidates: Array<PreferredNetworkAddress & { privateAddress: boolean }> = [];
29
+
30
+ for (const [interfaceName, addresses] of Object.entries(interfaces)) {
31
+ if (!addresses || isExcludedInterface(interfaceName)) continue;
32
+ const kind = classifyInterface(interfaceName);
33
+ for (const address of addresses) {
34
+ if (address.internal || address.family !== "IPv4" || !isUsableIpv4(address.address)) continue;
35
+ candidates.push({
36
+ address: address.address,
37
+ interfaceName,
38
+ kind,
39
+ privateAddress: isPrivateIpv4(address.address),
40
+ });
41
+ }
42
+ }
43
+
44
+ candidates.sort((left, right) => {
45
+ const kindDifference = KIND_PRIORITY[left.kind] - KIND_PRIORITY[right.kind];
46
+ if (kindDifference !== 0) return kindDifference;
47
+ if (left.privateAddress !== right.privateAddress) return left.privateAddress ? -1 : 1;
48
+ const interfaceDifference = compareText(left.interfaceName, right.interfaceName);
49
+ return interfaceDifference !== 0 ? interfaceDifference : compareIpv4(left.address, right.address);
50
+ });
51
+
52
+ const selected = candidates[0];
53
+ return selected
54
+ ? { address: selected.address, interfaceName: selected.interfaceName, kind: selected.kind }
55
+ : undefined;
56
+ }
57
+
58
+ export function classifyInterface(interfaceName: string): NetworkConnectionKind {
59
+ if (VPN_INTERFACE.test(interfaceName)) return "vpn";
60
+ if (WIFI_INTERFACE.test(interfaceName)) return "wifi";
61
+ if (WIRED_INTERFACE.test(interfaceName)) return "wired";
62
+ return "other";
63
+ }
64
+
65
+ function isExcludedInterface(interfaceName: string): boolean {
66
+ return /^lo\d*$/iu.test(interfaceName) || VIRTUAL_INTERFACE.test(interfaceName);
67
+ }
68
+
69
+ function isUsableIpv4(address: string): boolean {
70
+ const parts = ipv4Parts(address);
71
+ if (!parts) return false;
72
+ const [first, second] = parts;
73
+ return first !== 0
74
+ && first !== 127
75
+ && first < 224
76
+ && !(first === 169 && second === 254);
77
+ }
78
+
79
+ function isPrivateIpv4(address: string): boolean {
80
+ const parts = ipv4Parts(address);
81
+ if (!parts) return false;
82
+ const [first, second] = parts;
83
+ return first === 10
84
+ || (first === 172 && second >= 16 && second <= 31)
85
+ || (first === 192 && second === 168)
86
+ || (first === 100 && second >= 64 && second <= 127);
87
+ }
88
+
89
+ function ipv4Parts(address: string): [number, number, number, number] | undefined {
90
+ const values = address.split(".").map((part) => Number(part));
91
+ if (values.length !== 4 || values.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) {
92
+ return undefined;
93
+ }
94
+ return values as [number, number, number, number];
95
+ }
96
+
97
+ function compareText(left: string, right: string): number {
98
+ const normalizedLeft = left.toLocaleLowerCase("en-US");
99
+ const normalizedRight = right.toLocaleLowerCase("en-US");
100
+ return normalizedLeft < normalizedRight ? -1 : normalizedLeft > normalizedRight ? 1 : 0;
101
+ }
102
+
103
+ function compareIpv4(left: string, right: string): number {
104
+ const leftParts = ipv4Parts(left) ?? [0, 0, 0, 0];
105
+ const rightParts = ipv4Parts(right) ?? [0, 0, 0, 0];
106
+ for (let index = 0; index < leftParts.length; index += 1) {
107
+ const difference = leftParts[index]! - rightParts[index]!;
108
+ if (difference !== 0) return difference;
109
+ }
110
+ return 0;
111
+ }
@@ -41,6 +41,7 @@ import {
41
41
  } from "../feishu/GroupNameFormatter.js";
42
42
  import { normalizeFeishuPostText } from "../feishu/InboundText.js";
43
43
  import { allowsFeishuUser } from "../feishu/ownerAccess.js";
44
+ import { classifyFileContent } from "../local-files/LocalFileViewerServer.js";
44
45
  import type { OutboundRouter } from "../presentation/OutboundRouter.js";
45
46
  import type { TurnActivity, TurnViewState } from "../presentation/turnViewTypes.js";
46
47
  import { buildTurnGraphRows } from "../presentation/turnGraph.js";
@@ -124,6 +125,7 @@ const SESSION_PROMPT_PREVIEW_LENGTH = 50;
124
125
  const DIRECTORY_PAGE_SIZE = 15;
125
126
  const RESET_HISTORY_PAGE_SIZE = 10;
126
127
  const MAX_LLM_TURN_RETRIES = 3;
128
+ const QUEUED_PROMPT_RETRY_DELAY_MS = 5_000;
127
129
  const RECOVERY_ACTIVITY_WINDOW_MS = 10 * 60 * 1_000;
128
130
  const RECOVERY_HEARTBEAT_INTERVAL_MS = 60 * 1_000;
129
131
  const SHELL_COMMAND_JOB_POLL_INTERVAL_MS = 1_000;
@@ -131,17 +133,12 @@ const SHELL_COMMAND_JOB_RELAUNCH_DELAY_MS = 5_000;
131
133
  const FORWARD_ATTACHMENT_WINDOW_MS = 800;
132
134
  const DEFAULT_MERGED_FORWARD_INSTRUCTION = "请参考以下内容回复用户";
133
135
  const DEFAULT_REFERENCED_MESSAGE_INSTRUCTION = "请参考引用消息回复用户";
136
+ const TURN_STOP_REQUESTED_MESSAGE = "已发送停止信号,任务会自动停止。";
134
137
  const REMOTE_SESSION_REFERENCE_PREFIX = "agent-runtime:";
135
138
  const WINDOWS_DRIVES_DIRECTORY = "agentbot://windows-drives";
136
139
  const IMAGE_FILE_EXTENSIONS = new Set([
137
140
  ".avif", ".bmp", ".gif", ".heic", ".heif", ".ico", ".jpeg", ".jpg", ".png", ".svg", ".tif", ".tiff", ".webp",
138
141
  ]);
139
- const BINARY_FILE_EXTENSIONS = new Set([
140
- ".7z", ".a", ".apk", ".avi", ".bin", ".bz2", ".class", ".dat", ".db", ".deb", ".dll", ".dmg", ".doc", ".docx",
141
- ".dylib", ".eot", ".exe", ".flac", ".gz", ".ico", ".ipa", ".iso", ".jar", ".lib", ".mdb", ".mkv", ".mov", ".mp3",
142
- ".mp4", ".msi", ".o", ".obj", ".ogg", ".otf", ".pdf", ".ppt", ".pptx", ".rar", ".rpm", ".so", ".sqlite", ".tar",
143
- ".ttf", ".wav", ".webm", ".woff", ".woff2", ".xls", ".xlsx", ".xz", ".zip", ".zst",
144
- ]);
145
142
 
146
143
  interface HelpCommandDefinition {
147
144
  command: string;
@@ -432,6 +429,7 @@ export class ProxySessionController {
432
429
  private readonly threadInitializations = new Map<string, Promise<void>>();
433
430
  private readonly llmRetryingSessions = new Set<string>();
434
431
  private readonly retriedFailureTurnIds = new Set<string>();
432
+ private readonly queuedPromptRetryTimers = new Map<string, NodeJS.Timeout>();
435
433
  private readonly recoveryRetryTimers = new Map<string, NodeJS.Timeout>();
436
434
  private readonly pendingMergedForwards = new Map<string, PendingForwardAttachment>();
437
435
  private readonly pendingResourceForwards = new Map<string, PendingForwardAttachment>();
@@ -1101,6 +1099,8 @@ export class ProxySessionController {
1101
1099
  for (const pending of this.pendingResourceForwards.values()) pending.resolveAttachment(undefined);
1102
1100
  this.pendingResourceForwards.clear();
1103
1101
  this.relatedReactionMessageIds.clear();
1102
+ for (const timer of this.queuedPromptRetryTimers.values()) clearTimeout(timer);
1103
+ this.queuedPromptRetryTimers.clear();
1104
1104
  for (const timer of this.recoveryRetryTimers.values()) clearTimeout(timer);
1105
1105
  this.recoveryRetryTimers.clear();
1106
1106
  for (const [jobId] of this.shellCommandJobMonitors) this.stopShellCommandJobMonitor(jobId);
@@ -1675,9 +1675,10 @@ export class ProxySessionController {
1675
1675
  await this.assertBrowsableDirectory(directory);
1676
1676
  const entries = (await fs.readdir(directory, { withFileTypes: true }))
1677
1677
  .map((entry) => ({
1678
+ dirent: entry,
1678
1679
  name: entry.name,
1679
1680
  path: path.join(directory, entry.name),
1680
- kind: directoryBrowserEntryKind(entry),
1681
+ kind: entry.isDirectory() ? "directory" as const : "file" as const,
1681
1682
  }))
1682
1683
  .sort((left, right) => Number(right.kind === "directory") - Number(left.kind === "directory")
1683
1684
  || left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" }));
@@ -1690,7 +1691,15 @@ export class ProxySessionController {
1690
1691
  page,
1691
1692
  totalPages,
1692
1693
  totalEntries: entries.length,
1693
- entries: entries.slice(page * DIRECTORY_PAGE_SIZE, (page + 1) * DIRECTORY_PAGE_SIZE),
1694
+ entries: entries
1695
+ .slice(page * DIRECTORY_PAGE_SIZE, (page + 1) * DIRECTORY_PAGE_SIZE)
1696
+ .map((entry) => ({
1697
+ name: entry.name,
1698
+ path: entry.path,
1699
+ kind: entry.kind === "directory"
1700
+ ? "directory" as const
1701
+ : directoryBrowserEntryKind(entry.dirent, entry.path),
1702
+ })),
1694
1703
  };
1695
1704
  }
1696
1705
 
@@ -3988,6 +3997,11 @@ export class ProxySessionController {
3988
3997
  }
3989
3998
 
3990
3999
  private scheduleNextQueuedPrompt(sessionId: string): Promise<void> {
4000
+ const retryTimer = this.queuedPromptRetryTimers.get(sessionId);
4001
+ if (retryTimer) {
4002
+ clearTimeout(retryTimer);
4003
+ this.queuedPromptRetryTimers.delete(sessionId);
4004
+ }
3991
4005
  const previous = this.queuedPromptStarts.get(sessionId) ?? Promise.resolve();
3992
4006
  const next = previous.catch(() => undefined).then(() => this.startNextQueuedPromptIfIdle(sessionId));
3993
4007
  this.queuedPromptStarts.set(sessionId, next);
@@ -3996,6 +4010,23 @@ export class ProxySessionController {
3996
4010
  });
3997
4011
  }
3998
4012
 
4013
+ private scheduleQueuedPromptRetry(sessionId: string): void {
4014
+ if (this.queuedPromptRetryTimers.has(sessionId)) return;
4015
+ if (this.store.countQueuedPrompts(sessionId) === 0) return;
4016
+ const timer = setTimeout(() => {
4017
+ this.queuedPromptRetryTimers.delete(sessionId);
4018
+ void this.scheduleNextQueuedPrompt(sessionId).catch((error: unknown) => {
4019
+ this.logger.warn(
4020
+ { error, errorMessage: runtimeErrorMessage(error), sessionId },
4021
+ "Failed to retry queued prompt start.",
4022
+ );
4023
+ this.scheduleQueuedPromptRetry(sessionId);
4024
+ });
4025
+ }, QUEUED_PROMPT_RETRY_DELAY_MS);
4026
+ timer.unref?.();
4027
+ this.queuedPromptRetryTimers.set(sessionId, timer);
4028
+ }
4029
+
3999
4030
  private async startNextQueuedPromptIfIdle(sessionId: string): Promise<void> {
4000
4031
  if (this.store.countQueuedPrompts(sessionId) === 0) return;
4001
4032
  const baseRecord = this.store.getSession(sessionId);
@@ -4023,10 +4054,22 @@ export class ProxySessionController {
4023
4054
  );
4024
4055
  if (prompt.messageId) await this.bindMessageReactionToTurn(prompt.messageId, sessionId, turnId);
4025
4056
  } catch (error) {
4026
- this.logger.warn({ error, sessionId }, "Failed to start queued prompt.");
4027
- if (prompt?.messageId) await this.finalizeStandaloneMessageReaction(prompt.messageId, "failed");
4028
- if (prompt) await this.sendError(prompt.contextKey, error);
4029
- if (prompt && this.store.countQueuedPrompts(sessionId) > 0) {
4057
+ this.logger.warn(
4058
+ {
4059
+ error,
4060
+ errorMessage: runtimeErrorMessage(error),
4061
+ phase: prompt ? "start" : "preflight",
4062
+ sessionId,
4063
+ },
4064
+ "Failed to start queued prompt.",
4065
+ );
4066
+ if (!prompt) {
4067
+ this.scheduleQueuedPromptRetry(sessionId);
4068
+ return;
4069
+ }
4070
+ if (prompt.messageId) await this.finalizeStandaloneMessageReaction(prompt.messageId, "failed");
4071
+ await this.sendError(prompt.contextKey, error);
4072
+ if (this.store.countQueuedPrompts(sessionId) > 0) {
4030
4073
  queueMicrotask(() => void this.scheduleNextQueuedPrompt(sessionId));
4031
4074
  }
4032
4075
  }
@@ -4247,7 +4290,7 @@ export class ProxySessionController {
4247
4290
  remoteSessionId: record.remoteSessionId,
4248
4291
  turnId,
4249
4292
  });
4250
- await this.outbound.sendText(record.contextKey, `已向 Agent 发送 Interrupt 请求:${turnId}`);
4293
+ await this.outbound.sendText(record.contextKey, TURN_STOP_REQUESTED_MESSAGE);
4251
4294
  return;
4252
4295
  }
4253
4296
 
@@ -5347,9 +5390,10 @@ export class ProxySessionController {
5347
5390
  }
5348
5391
  const entries = directoryEntries
5349
5392
  .map((entry) => ({
5393
+ dirent: entry,
5350
5394
  name: entry.name,
5351
5395
  path: path.join(directory, entry.name),
5352
- kind: directoryBrowserEntryKind(entry),
5396
+ kind: entry.isDirectory() ? "directory" as const : "file" as const,
5353
5397
  }))
5354
5398
  .sort((left, right) => Number(right.kind === "directory") - Number(left.kind === "directory")
5355
5399
  || left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" }));
@@ -5377,8 +5421,9 @@ export class ProxySessionController {
5377
5421
  ...parentEntries,
5378
5422
  ...entries
5379
5423
  .slice(offset, offset + pageSize)
5380
- .map((entry): DirectoryBrowserCardEntry => entry.kind === "directory"
5381
- ? {
5424
+ .map((entry): DirectoryBrowserCardEntry => {
5425
+ if (entry.kind === "directory") {
5426
+ return {
5382
5427
  name: entry.name,
5383
5428
  kind: entry.kind,
5384
5429
  openAction: {
@@ -5389,10 +5434,11 @@ export class ProxySessionController {
5389
5434
  contextKey,
5390
5435
  },
5391
5436
  },
5392
- }
5393
- : {
5437
+ };
5438
+ }
5439
+ return {
5394
5440
  name: entry.name,
5395
- kind: entry.kind,
5441
+ kind: directoryBrowserEntryKind(entry.dirent, entry.path),
5396
5442
  openAction: {
5397
5443
  text: entry.name,
5398
5444
  value: {
@@ -5401,7 +5447,8 @@ export class ProxySessionController {
5401
5447
  contextKey,
5402
5448
  },
5403
5449
  },
5404
- }),
5450
+ };
5451
+ }),
5405
5452
  ];
5406
5453
  const navigationActions = directoryPaginationActions(directory, page, totalPages, contextKey);
5407
5454
  const directoryCount = entries.filter((entry) => entry.kind === "directory").length;
@@ -5903,7 +5950,7 @@ export class ProxySessionController {
5903
5950
  turnId,
5904
5951
  source: "sessions_card",
5905
5952
  });
5906
- await this.outbound.sendText(contextKey, `已向 Agent 发送 Interrupt 请求:${turnId}`);
5953
+ await this.outbound.sendText(contextKey, TURN_STOP_REQUESTED_MESSAGE);
5907
5954
  }
5908
5955
 
5909
5956
  private async archiveSessionReference(
@@ -7114,12 +7161,16 @@ function isWindowsDriveRoot(directory: string): boolean {
7114
7161
  return /^[a-z]:\\$/iu.test(path.win32.normalize(directory));
7115
7162
  }
7116
7163
 
7117
- function directoryBrowserEntryKind(entry: Dirent): DirectoryBrowserCardEntry["kind"] {
7164
+ function directoryBrowserEntryKind(entry: Dirent, filePath: string): DirectoryBrowserCardEntry["kind"] {
7118
7165
  if (entry.isDirectory()) return "directory";
7166
+ try {
7167
+ if (classifyFileContent(filePath).kind === "text") return "file";
7168
+ } catch {
7169
+ return "file";
7170
+ }
7119
7171
  const extension = path.extname(entry.name).toLowerCase();
7120
7172
  if (IMAGE_FILE_EXTENSIONS.has(extension)) return "image";
7121
- if (BINARY_FILE_EXTENSIONS.has(extension)) return "binary";
7122
- return "file";
7173
+ return "binary";
7123
7174
  }
7124
7175
 
7125
7176
  function directoryActionPath(value: unknown): string {
@@ -48,8 +48,13 @@ export class StartupNotifier {
48
48
  .filter((chat) => !isThreadContextKey(chat.contextKey))
49
49
  .map((chat) => [chat.contextKey, chat]),
50
50
  );
51
+ const knownPrivateChats = new Map(
52
+ this.store.listChatContexts("p2p")
53
+ .filter((chat) => !isThreadContextKey(chat.contextKey))
54
+ .map((chat) => [chat.contextKey, chat]),
55
+ );
51
56
  const targetsByContext = new Map<string, StartupNotificationTarget>();
52
- for (const chat of this.store.listChatContexts("p2p")) {
57
+ for (const chat of knownPrivateChats.values()) {
53
58
  targetsByContext.set(chat.contextKey, {
54
59
  contextKey: chat.contextKey,
55
60
  context: this.store.getUserContext(chat.contextKey),
@@ -71,10 +76,11 @@ export class StartupNotifier {
71
76
  if (!contextKey) continue;
72
77
  if (isThreadContextKey(contextKey)) {
73
78
  const baseContextKey = baseChatContextKey(contextKey);
74
- if (!knownGroups.has(baseContextKey)) continue;
79
+ const parentChat = knownGroups.get(baseContextKey) ?? knownPrivateChats.get(baseContextKey);
80
+ if (!parentChat) continue;
75
81
  const replyMessageId = target.replyMessageId?.trim();
76
82
  if (replyMessageId) {
77
- if (!explicitBaseContextKeys.has(baseContextKey)) {
83
+ if (parentChat.chatType === "group" && !explicitBaseContextKeys.has(baseContextKey)) {
78
84
  targetsByContext.delete(baseContextKey);
79
85
  }
80
86
  targetsByContext.set(contextKey, {
@@ -83,7 +89,6 @@ export class StartupNotifier {
83
89
  context: this.store.getUserContext(contextKey),
84
90
  });
85
91
  } else {
86
- const group = knownGroups.get(baseContextKey)!;
87
92
  targetsByContext.set(baseContextKey, {
88
93
  contextKey: baseContextKey,
89
94
  context: this.store.getUserContext(baseContextKey),
@@ -35,6 +35,10 @@ export interface SafeRestartSchedulerOptions {
35
35
  pollIntervalMs?: number;
36
36
  }
37
37
 
38
+ export interface SafeRestartScheduleOptions {
39
+ restartImmediatelyIfIdle?: boolean;
40
+ }
41
+
38
42
  export class SafeRestartScheduler {
39
43
  private timer?: NodeJS.Timeout;
40
44
  private polling?: Promise<void>;
@@ -44,6 +48,7 @@ export class SafeRestartScheduler {
44
48
  private idleSince?: number;
45
49
  private idleInboundAt?: string;
46
50
  private scheduleId = 0;
51
+ private restartImmediatelyIfIdle = false;
47
52
 
48
53
  constructor(private readonly options: SafeRestartSchedulerOptions) {}
49
54
 
@@ -55,10 +60,17 @@ export class SafeRestartScheduler {
55
60
  return this.reason;
56
61
  }
57
62
 
58
- schedule(reason: string, notificationTarget?: RestartNotificationTarget): boolean {
63
+ schedule(
64
+ reason: string,
65
+ notificationTarget?: RestartNotificationTarget,
66
+ options: SafeRestartScheduleOptions = {},
67
+ ): boolean {
59
68
  const newlyScheduled = !this.reason;
60
69
  if (newlyScheduled) {
61
70
  this.notificationTargets = [];
71
+ this.restartImmediatelyIfIdle = options.restartImmediatelyIfIdle === true;
72
+ } else if (options.restartImmediatelyIfIdle) {
73
+ this.restartImmediatelyIfIdle = true;
62
74
  }
63
75
  this.scheduleId += 1;
64
76
  this.addNotificationTarget(notificationTarget, reason);
@@ -104,6 +116,7 @@ export class SafeRestartScheduler {
104
116
  this.notificationTargets = [];
105
117
  this.idleSince = undefined;
106
118
  this.idleInboundAt = undefined;
119
+ this.restartImmediatelyIfIdle = false;
107
120
  }
108
121
 
109
122
  private async poll(): Promise<void> {
@@ -123,6 +136,10 @@ export class SafeRestartScheduler {
123
136
  });
124
137
  return;
125
138
  }
139
+ if (this.restartImmediatelyIfIdle) {
140
+ await this.restart(reason, scheduleId, state);
141
+ return;
142
+ }
126
143
  const quietPeriodMs = this.options.quietPeriodMs ?? 15_000;
127
144
  const latestInboundAt = state.latestInboundAt ?? "";
128
145
  if (this.idleSince === undefined || this.idleInboundAt !== latestInboundAt) {
@@ -178,6 +195,10 @@ export class SafeRestartScheduler {
178
195
  }
179
196
  return;
180
197
  }
198
+ await this.restart(reason, scheduleId, confirmed);
199
+ }
200
+
201
+ private async restart(reason: string, scheduleId: number, activity: ServerActivityState): Promise<void> {
181
202
  const notificationTargets = this.notificationTargetsSnapshot();
182
203
  this.clear();
183
204
  await this.emitStatus({
@@ -185,7 +206,7 @@ export class SafeRestartScheduler {
185
206
  reason,
186
207
  notificationTargets,
187
208
  phase: "restarting",
188
- activity: confirmed,
209
+ activity,
189
210
  remainingMs: 0,
190
211
  });
191
212
  await this.options.onReady(reason, notificationTargets);