autonomous-flow-daemon 1.6.0 → 1.9.0

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 (61) hide show
  1. package/CHANGELOG.md +85 -85
  2. package/LICENSE +21 -21
  3. package/README-ko.md +282 -0
  4. package/README.md +282 -266
  5. package/mcp-config.json +10 -10
  6. package/package.json +4 -2
  7. package/src/adapters/index.ts +370 -370
  8. package/src/cli.ts +162 -127
  9. package/src/commands/benchmark.ts +187 -187
  10. package/src/commands/correlate.ts +180 -0
  11. package/src/commands/dashboard.ts +404 -0
  12. package/src/commands/evolution.ts +84 -1
  13. package/src/commands/fix.ts +158 -158
  14. package/src/commands/lang.ts +41 -41
  15. package/src/commands/plugin.ts +110 -0
  16. package/src/commands/restart.ts +14 -14
  17. package/src/commands/score.ts +276 -276
  18. package/src/commands/start.ts +155 -155
  19. package/src/commands/status.ts +157 -157
  20. package/src/commands/stop.ts +68 -68
  21. package/src/commands/suggest.ts +211 -0
  22. package/src/commands/sync.ts +329 -16
  23. package/src/constants.ts +32 -32
  24. package/src/core/boast.ts +280 -280
  25. package/src/core/config.ts +49 -49
  26. package/src/core/correlation-engine.ts +265 -0
  27. package/src/core/db.ts +145 -117
  28. package/src/core/discovery.ts +65 -65
  29. package/src/core/federation.ts +129 -0
  30. package/src/core/hologram/engine.ts +71 -71
  31. package/src/core/hologram/fallback.ts +11 -11
  32. package/src/core/hologram/go-extractor.ts +203 -0
  33. package/src/core/hologram/incremental.ts +227 -227
  34. package/src/core/hologram/py-extractor.ts +132 -132
  35. package/src/core/hologram/rust-extractor.ts +244 -0
  36. package/src/core/hologram/ts-extractor.ts +406 -320
  37. package/src/core/hologram/types.ts +27 -25
  38. package/src/core/hologram.ts +73 -71
  39. package/src/core/i18n/messages.ts +309 -309
  40. package/src/core/locale.ts +88 -88
  41. package/src/core/log-rotate.ts +33 -33
  42. package/src/core/log-utils.ts +38 -38
  43. package/src/core/lru-map.ts +61 -61
  44. package/src/core/notify.ts +74 -74
  45. package/src/core/plugin-manager.ts +225 -0
  46. package/src/core/rule-suggestion.ts +127 -0
  47. package/src/core/validator-generator.ts +224 -0
  48. package/src/core/workspace.ts +28 -28
  49. package/src/daemon/client.ts +78 -65
  50. package/src/daemon/event-batcher.ts +108 -108
  51. package/src/daemon/guards.ts +13 -13
  52. package/src/daemon/http-routes.ts +376 -293
  53. package/src/daemon/mcp-handler.ts +575 -270
  54. package/src/daemon/mcp-subscriptions.ts +81 -0
  55. package/src/daemon/mesh.ts +51 -0
  56. package/src/daemon/server.ts +655 -590
  57. package/src/daemon/types.ts +121 -100
  58. package/src/daemon/workspace-map.ts +104 -92
  59. package/src/platform.ts +60 -60
  60. package/src/version.ts +15 -15
  61. package/README.ko.md +0 -266
@@ -1,88 +1,88 @@
1
- /**
2
- * OS Locale Detection — cached per-process.
3
- *
4
- * Priority:
5
- * 0. ~/.afdrc config file (persistent user preference via `afd lang`)
6
- * 1. AFD_LANG env (explicit override per-session)
7
- * 2. LC_ALL / LANG / LANGUAGE env (user shell config, skip "C"/"POSIX")
8
- * 3. macOS AppleLocale (system preferences — solves LANG=C.UTF-8 on Korean macOS)
9
- * 4. Intl API (runtime default)
10
- * 5. Default: "ko"
11
- */
12
-
13
- import { execSync } from "child_process";
14
- import { readConfig } from "./config";
15
-
16
- export type SupportedLang = "en" | "ko";
17
-
18
- const SUPPORTED: SupportedLang[] = ["en", "ko"];
19
-
20
- let cached: SupportedLang | null = null;
21
-
22
- function isSupported(value: string): value is SupportedLang {
23
- return SUPPORTED.includes(value as SupportedLang);
24
- }
25
-
26
- function matchKo(value: string): boolean {
27
- return value.startsWith("ko");
28
- }
29
-
30
- /** Detect system language. Returns 'ko' or 'en'. */
31
- export function getSystemLanguage(): SupportedLang {
32
- if (cached) return cached;
33
-
34
- // 0. Persistent config (~/.afdrc)
35
- const rc = readConfig();
36
- if (rc.lang && isSupported(rc.lang)) {
37
- cached = rc.lang as SupportedLang;
38
- return cached;
39
- }
40
-
41
- // 1. Explicit env override
42
- const afdLang = process.env.AFD_LANG ?? "";
43
- if (isSupported(afdLang)) { cached = afdLang; return cached; }
44
- if (matchKo(afdLang)) { cached = "ko"; return cached; }
45
-
46
- // 2. Standard env — skip generic "C" / "POSIX"
47
- const envLang = process.env.LC_ALL || process.env.LANG || process.env.LANGUAGE || "";
48
- if (envLang !== "" && !envLang.startsWith("C") && !envLang.startsWith("POSIX")) {
49
- if (matchKo(envLang)) { cached = "ko"; return cached; }
50
- cached = "en";
51
- return cached;
52
- }
53
-
54
- // 3. macOS: AppleLocale
55
- if (process.platform === "darwin") {
56
- try {
57
- const appleLocale = execSync("defaults read -g AppleLocale", {
58
- encoding: "utf-8",
59
- timeout: 500,
60
- }).trim();
61
- if (matchKo(appleLocale)) { cached = "ko"; return cached; }
62
- } catch {
63
- // Not macOS or defaults unavailable
64
- }
65
- }
66
-
67
- // 4. Intl API fallback
68
- try {
69
- const intlLocale = Intl.DateTimeFormat().resolvedOptions().locale;
70
- if (matchKo(intlLocale)) { cached = "ko"; return cached; }
71
- } catch {
72
- // Fallback to default
73
- }
74
-
75
- // 5. Default
76
- cached = "ko";
77
- return cached;
78
- }
79
-
80
- /** Get list of supported languages. */
81
- export function getSupportedLanguages(): SupportedLang[] {
82
- return [...SUPPORTED];
83
- }
84
-
85
- /** Override locale (for testing or after `afd lang` write). */
86
- export function setLanguageOverride(lang: SupportedLang | null): void {
87
- cached = lang;
88
- }
1
+ /**
2
+ * OS Locale Detection — cached per-process.
3
+ *
4
+ * Priority:
5
+ * 0. ~/.afdrc config file (persistent user preference via `afd lang`)
6
+ * 1. AFD_LANG env (explicit override per-session)
7
+ * 2. LC_ALL / LANG / LANGUAGE env (user shell config, skip "C"/"POSIX")
8
+ * 3. macOS AppleLocale (system preferences — solves LANG=C.UTF-8 on Korean macOS)
9
+ * 4. Intl API (runtime default)
10
+ * 5. Default: "ko"
11
+ */
12
+
13
+ import { execSync } from "child_process";
14
+ import { readConfig } from "./config";
15
+
16
+ export type SupportedLang = "en" | "ko";
17
+
18
+ const SUPPORTED: SupportedLang[] = ["en", "ko"];
19
+
20
+ let cached: SupportedLang | null = null;
21
+
22
+ function isSupported(value: string): value is SupportedLang {
23
+ return SUPPORTED.includes(value as SupportedLang);
24
+ }
25
+
26
+ function matchKo(value: string): boolean {
27
+ return value.startsWith("ko");
28
+ }
29
+
30
+ /** Detect system language. Returns 'ko' or 'en'. */
31
+ export function getSystemLanguage(): SupportedLang {
32
+ if (cached) return cached;
33
+
34
+ // 0. Persistent config (~/.afdrc)
35
+ const rc = readConfig();
36
+ if (rc.lang && isSupported(rc.lang)) {
37
+ cached = rc.lang as SupportedLang;
38
+ return cached;
39
+ }
40
+
41
+ // 1. Explicit env override
42
+ const afdLang = process.env.AFD_LANG ?? "";
43
+ if (isSupported(afdLang)) { cached = afdLang; return cached; }
44
+ if (matchKo(afdLang)) { cached = "ko"; return cached; }
45
+
46
+ // 2. Standard env — skip generic "C" / "POSIX"
47
+ const envLang = process.env.LC_ALL || process.env.LANG || process.env.LANGUAGE || "";
48
+ if (envLang !== "" && !envLang.startsWith("C") && !envLang.startsWith("POSIX")) {
49
+ if (matchKo(envLang)) { cached = "ko"; return cached; }
50
+ cached = "en";
51
+ return cached;
52
+ }
53
+
54
+ // 3. macOS: AppleLocale
55
+ if (process.platform === "darwin") {
56
+ try {
57
+ const appleLocale = execSync("defaults read -g AppleLocale", {
58
+ encoding: "utf-8",
59
+ timeout: 500,
60
+ }).trim();
61
+ if (matchKo(appleLocale)) { cached = "ko"; return cached; }
62
+ } catch {
63
+ // Not macOS or defaults unavailable
64
+ }
65
+ }
66
+
67
+ // 4. Intl API fallback
68
+ try {
69
+ const intlLocale = Intl.DateTimeFormat().resolvedOptions().locale;
70
+ if (matchKo(intlLocale)) { cached = "ko"; return cached; }
71
+ } catch {
72
+ // Fallback to default
73
+ }
74
+
75
+ // 5. Default
76
+ cached = "ko";
77
+ return cached;
78
+ }
79
+
80
+ /** Get list of supported languages. */
81
+ export function getSupportedLanguages(): SupportedLang[] {
82
+ return [...SUPPORTED];
83
+ }
84
+
85
+ /** Override locale (for testing or after `afd lang` write). */
86
+ export function setLanguageOverride(lang: SupportedLang | null): void {
87
+ cached = lang;
88
+ }
@@ -1,33 +1,33 @@
1
- import { existsSync, statSync, renameSync, unlinkSync } from "fs";
2
-
3
- const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5 MB
4
- const MAX_ROTATED_FILES = 3; // daemon.log.1, .2, .3
5
-
6
- /**
7
- * Rotate log file if it exceeds MAX_LOG_SIZE.
8
- * Keeps up to MAX_ROTATED_FILES old logs.
9
- *
10
- * daemon.log → daemon.log.1 → daemon.log.2 → daemon.log.3 (deleted)
11
- */
12
- export function rotateLogIfNeeded(logPath: string): void {
13
- if (!existsSync(logPath)) return;
14
-
15
- try {
16
- const { size } = statSync(logPath);
17
- if (size < MAX_LOG_SIZE) return;
18
-
19
- // Shift existing rotated files: .3→delete, .2→.3, .1→.2
20
- for (let i = MAX_ROTATED_FILES; i >= 1; i--) {
21
- const src = i === 1 ? logPath : `${logPath}.${i - 1}`;
22
- const dst = `${logPath}.${i}`;
23
- if (!existsSync(src)) continue;
24
- if (i === MAX_ROTATED_FILES && existsSync(dst)) {
25
- unlinkSync(dst);
26
- }
27
- renameSync(src, dst);
28
- }
29
- // logPath has been renamed to logPath.1, fresh log will be created on open
30
- } catch {
31
- // Non-critical: if rotation fails, just keep appending
32
- }
33
- }
1
+ import { existsSync, statSync, renameSync, unlinkSync } from "fs";
2
+
3
+ const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5 MB
4
+ const MAX_ROTATED_FILES = 3; // daemon.log.1, .2, .3
5
+
6
+ /**
7
+ * Rotate log file if it exceeds MAX_LOG_SIZE.
8
+ * Keeps up to MAX_ROTATED_FILES old logs.
9
+ *
10
+ * daemon.log → daemon.log.1 → daemon.log.2 → daemon.log.3 (deleted)
11
+ */
12
+ export function rotateLogIfNeeded(logPath: string): void {
13
+ if (!existsSync(logPath)) return;
14
+
15
+ try {
16
+ const { size } = statSync(logPath);
17
+ if (size < MAX_LOG_SIZE) return;
18
+
19
+ // Shift existing rotated files: .3→delete, .2→.3, .1→.2
20
+ for (let i = MAX_ROTATED_FILES; i >= 1; i--) {
21
+ const src = i === 1 ? logPath : `${logPath}.${i - 1}`;
22
+ const dst = `${logPath}.${i}`;
23
+ if (!existsSync(src)) continue;
24
+ if (i === MAX_ROTATED_FILES && existsSync(dst)) {
25
+ unlinkSync(dst);
26
+ }
27
+ renameSync(src, dst);
28
+ }
29
+ // logPath has been renamed to logPath.1, fresh log will be created on open
30
+ } catch {
31
+ // Non-critical: if rotation fails, just keep appending
32
+ }
33
+ }
@@ -1,38 +1,38 @@
1
- /**
2
- * Logging utilities for the afd daemon.
3
- * Extracted for testability and reuse.
4
- */
5
-
6
- /** Format current time as HH:MM:SS.mmm */
7
- export function formatTimestamp(date: Date = new Date()): string {
8
- const hh = String(date.getHours()).padStart(2, "0");
9
- const mm = String(date.getMinutes()).padStart(2, "0");
10
- const ss = String(date.getSeconds()).padStart(2, "0");
11
- const ms = String(date.getMilliseconds()).padStart(3, "0");
12
- return `${hh}:${mm}:${ss}.${ms}`;
13
- }
14
-
15
- /**
16
- * Build a concise line-diff between old and new content.
17
- * Returns at most `maxLines` diff entries (default 10).
18
- */
19
- export function lineDiff(oldText: string, newText: string, maxLines = 10): string[] {
20
- const oldLines = oldText.split("\n");
21
- const newLines = newText.split("\n");
22
- const diffs: string[] = [];
23
- const maxLen = Math.max(oldLines.length, newLines.length);
24
- for (let i = 0; i < maxLen; i++) {
25
- if (oldLines[i] !== newLines[i]) {
26
- const ln = i + 1;
27
- if (i < oldLines.length && i < newLines.length) {
28
- diffs.push(` L${ln}: "${oldLines[i].trimEnd()}" → "${newLines[i].trimEnd()}"`);
29
- } else if (i < oldLines.length) {
30
- diffs.push(` L${ln}: - "${oldLines[i].trimEnd()}"`);
31
- } else {
32
- diffs.push(` L${ln}: + "${newLines[i]!.trimEnd()}"`);
33
- }
34
- }
35
- if (diffs.length >= maxLines) { diffs.push(" ... (truncated)"); break; }
36
- }
37
- return diffs;
38
- }
1
+ /**
2
+ * Logging utilities for the afd daemon.
3
+ * Extracted for testability and reuse.
4
+ */
5
+
6
+ /** Format current time as HH:MM:SS.mmm */
7
+ export function formatTimestamp(date: Date = new Date()): string {
8
+ const hh = String(date.getHours()).padStart(2, "0");
9
+ const mm = String(date.getMinutes()).padStart(2, "0");
10
+ const ss = String(date.getSeconds()).padStart(2, "0");
11
+ const ms = String(date.getMilliseconds()).padStart(3, "0");
12
+ return `${hh}:${mm}:${ss}.${ms}`;
13
+ }
14
+
15
+ /**
16
+ * Build a concise line-diff between old and new content.
17
+ * Returns at most `maxLines` diff entries (default 10).
18
+ */
19
+ export function lineDiff(oldText: string, newText: string, maxLines = 10): string[] {
20
+ const oldLines = oldText.split("\n");
21
+ const newLines = newText.split("\n");
22
+ const diffs: string[] = [];
23
+ const maxLen = Math.max(oldLines.length, newLines.length);
24
+ for (let i = 0; i < maxLen; i++) {
25
+ if (oldLines[i] !== newLines[i]) {
26
+ const ln = i + 1;
27
+ if (i < oldLines.length && i < newLines.length) {
28
+ diffs.push(` L${ln}: "${oldLines[i].trimEnd()}" → "${newLines[i].trimEnd()}"`);
29
+ } else if (i < oldLines.length) {
30
+ diffs.push(` L${ln}: - "${oldLines[i].trimEnd()}"`);
31
+ } else {
32
+ diffs.push(` L${ln}: + "${newLines[i]!.trimEnd()}"`);
33
+ }
34
+ }
35
+ if (diffs.length >= maxLines) { diffs.push(" ... (truncated)"); break; }
36
+ }
37
+ return diffs;
38
+ }
@@ -1,61 +1,61 @@
1
- /**
2
- * Size-bounded LRU Map that evicts by total byte size of values.
3
- * Uses Map insertion order for LRU eviction (oldest first).
4
- */
5
- export class LruStringMap {
6
- private map = new Map<string, string>();
7
- private currentBytes = 0;
8
-
9
- constructor(private readonly maxBytes: number) {}
10
-
11
- get(key: string): string | undefined {
12
- const val = this.map.get(key);
13
- if (val !== undefined) {
14
- // Move to end (most recently used)
15
- this.map.delete(key);
16
- this.map.set(key, val);
17
- }
18
- return val;
19
- }
20
-
21
- /** Returns false if the value was too large to store (exceeds maxBytes). */
22
- set(key: string, value: string): boolean {
23
- const valueBytes = value.length * 2;
24
-
25
- // Skip if single value exceeds budget (check BEFORE removing old entry)
26
- if (valueBytes > this.maxBytes) return false;
27
-
28
- // Remove old entry if exists
29
- const old = this.map.get(key);
30
- if (old !== undefined) {
31
- this.currentBytes -= old.length * 2; // JS string ≈ 2 bytes per char
32
- this.map.delete(key);
33
- }
34
-
35
- // Evict oldest entries until we have room
36
- while (this.currentBytes + valueBytes > this.maxBytes && this.map.size > 0) {
37
- const first = this.map.keys().next();
38
- if (first.done) break;
39
- const evicted = this.map.get(first.value)!;
40
- this.currentBytes -= evicted.length * 2;
41
- this.map.delete(first.value);
42
- }
43
-
44
- this.map.set(key, value);
45
- this.currentBytes += valueBytes;
46
- return true;
47
- }
48
-
49
- delete(key: string): boolean {
50
- const val = this.map.get(key);
51
- if (val !== undefined) {
52
- this.currentBytes -= val.length * 2;
53
- this.map.delete(key);
54
- return true;
55
- }
56
- return false;
57
- }
58
-
59
- get size(): number { return this.map.size; }
60
- get bytes(): number { return this.currentBytes; }
61
- }
1
+ /**
2
+ * Size-bounded LRU Map that evicts by total byte size of values.
3
+ * Uses Map insertion order for LRU eviction (oldest first).
4
+ */
5
+ export class LruStringMap {
6
+ private map = new Map<string, string>();
7
+ private currentBytes = 0;
8
+
9
+ constructor(private readonly maxBytes: number) {}
10
+
11
+ get(key: string): string | undefined {
12
+ const val = this.map.get(key);
13
+ if (val !== undefined) {
14
+ // Move to end (most recently used)
15
+ this.map.delete(key);
16
+ this.map.set(key, val);
17
+ }
18
+ return val;
19
+ }
20
+
21
+ /** Returns false if the value was too large to store (exceeds maxBytes). */
22
+ set(key: string, value: string): boolean {
23
+ const valueBytes = value.length * 2;
24
+
25
+ // Skip if single value exceeds budget (check BEFORE removing old entry)
26
+ if (valueBytes > this.maxBytes) return false;
27
+
28
+ // Remove old entry if exists
29
+ const old = this.map.get(key);
30
+ if (old !== undefined) {
31
+ this.currentBytes -= old.length * 2; // JS string ≈ 2 bytes per char
32
+ this.map.delete(key);
33
+ }
34
+
35
+ // Evict oldest entries until we have room
36
+ while (this.currentBytes + valueBytes > this.maxBytes && this.map.size > 0) {
37
+ const first = this.map.keys().next();
38
+ if (first.done) break;
39
+ const evicted = this.map.get(first.value)!;
40
+ this.currentBytes -= evicted.length * 2;
41
+ this.map.delete(first.value);
42
+ }
43
+
44
+ this.map.set(key, value);
45
+ this.currentBytes += valueBytes;
46
+ return true;
47
+ }
48
+
49
+ delete(key: string): boolean {
50
+ const val = this.map.get(key);
51
+ if (val !== undefined) {
52
+ this.currentBytes -= val.length * 2;
53
+ this.map.delete(key);
54
+ return true;
55
+ }
56
+ return false;
57
+ }
58
+
59
+ get size(): number { return this.map.size; }
60
+ get bytes(): number { return this.currentBytes; }
61
+ }
@@ -1,74 +1,74 @@
1
- import { spawn } from "child_process";
2
- import { IS_WINDOWS, IS_MACOS } from "../platform";
3
-
4
- /**
5
- * Fire an OS-native toast notification.
6
- * Runs asynchronously, never blocks, silently ignores all errors.
7
- *
8
- * - Windows 10+: PowerShell BalloonTip
9
- * - macOS: osascript display notification
10
- * - Linux: notify-send (libnotify)
11
- */
12
- export function notifyAutoHeal(patternId: string): void {
13
- const title = "\u{1F6E1}\uFE0F afd Auto-Healed";
14
- const body = `Silently fixed: ${patternId}`;
15
-
16
- try {
17
- if (IS_WINDOWS) {
18
- notifyWindows(title, body);
19
- } else if (IS_MACOS) {
20
- notifyMacOS(title, body);
21
- } else {
22
- notifyLinux(title, body);
23
- }
24
- } catch {
25
- // Crash-only: silently ignore notification failures
26
- }
27
- }
28
-
29
- /** Escape single quotes for PowerShell string literals */
30
- function escapePS(s: string): string {
31
- return s.replace(/'/g, "''");
32
- }
33
-
34
- /** Escape double quotes and backslashes for AppleScript string literals */
35
- function escapeAS(s: string): string {
36
- return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
37
- }
38
-
39
- function safeSpawn(cmd: string, args: string[], opts: Record<string, unknown> = {}): void {
40
- try {
41
- const child = spawn(cmd, args, { detached: true, stdio: "ignore", ...opts });
42
- child.on("error", () => {}); // Swallow async ENOENT when binary is missing
43
- child.unref();
44
- } catch {
45
- // Binary not found or spawn failed — silently ignore
46
- }
47
- }
48
-
49
- function notifyWindows(title: string, body: string): void {
50
- const ps = `
51
- Add-Type -AssemblyName System.Windows.Forms
52
- $n = New-Object System.Windows.Forms.NotifyIcon
53
- $n.Icon = [System.Drawing.SystemIcons]::Shield
54
- $n.BalloonTipTitle = '${escapePS(title)}'
55
- $n.BalloonTipText = '${escapePS(body)}'
56
- $n.BalloonTipIcon = 'Info'
57
- $n.Visible = $true
58
- $n.ShowBalloonTip(3000)
59
- Start-Sleep -Milliseconds 3500
60
- $n.Dispose()
61
- `.replace(/\n\s*/g, " ");
62
-
63
- safeSpawn("powershell", ["-NoProfile", "-NonInteractive", "-Command", ps], { windowsHide: true });
64
- }
65
-
66
- function notifyMacOS(title: string, body: string): void {
67
- const script = `display notification "${escapeAS(body)}" with title "${escapeAS(title)}"`;
68
-
69
- safeSpawn("osascript", ["-e", script]);
70
- }
71
-
72
- function notifyLinux(title: string, body: string): void {
73
- safeSpawn("notify-send", [title, body, "--icon=dialog-information"]);
74
- }
1
+ import { spawn } from "child_process";
2
+ import { IS_WINDOWS, IS_MACOS } from "../platform";
3
+
4
+ /**
5
+ * Fire an OS-native toast notification.
6
+ * Runs asynchronously, never blocks, silently ignores all errors.
7
+ *
8
+ * - Windows 10+: PowerShell BalloonTip
9
+ * - macOS: osascript display notification
10
+ * - Linux: notify-send (libnotify)
11
+ */
12
+ export function notifyAutoHeal(patternId: string): void {
13
+ const title = "\u{1F6E1}\uFE0F afd Auto-Healed";
14
+ const body = `Silently fixed: ${patternId}`;
15
+
16
+ try {
17
+ if (IS_WINDOWS) {
18
+ notifyWindows(title, body);
19
+ } else if (IS_MACOS) {
20
+ notifyMacOS(title, body);
21
+ } else {
22
+ notifyLinux(title, body);
23
+ }
24
+ } catch {
25
+ // Crash-only: silently ignore notification failures
26
+ }
27
+ }
28
+
29
+ /** Escape single quotes for PowerShell string literals */
30
+ function escapePS(s: string): string {
31
+ return s.replace(/'/g, "''");
32
+ }
33
+
34
+ /** Escape double quotes and backslashes for AppleScript string literals */
35
+ function escapeAS(s: string): string {
36
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
37
+ }
38
+
39
+ function safeSpawn(cmd: string, args: string[], opts: Record<string, unknown> = {}): void {
40
+ try {
41
+ const child = spawn(cmd, args, { detached: true, stdio: "ignore", ...opts });
42
+ child.on("error", () => {}); // Swallow async ENOENT when binary is missing
43
+ child.unref();
44
+ } catch {
45
+ // Binary not found or spawn failed — silently ignore
46
+ }
47
+ }
48
+
49
+ function notifyWindows(title: string, body: string): void {
50
+ const ps = `
51
+ Add-Type -AssemblyName System.Windows.Forms
52
+ $n = New-Object System.Windows.Forms.NotifyIcon
53
+ $n.Icon = [System.Drawing.SystemIcons]::Shield
54
+ $n.BalloonTipTitle = '${escapePS(title)}'
55
+ $n.BalloonTipText = '${escapePS(body)}'
56
+ $n.BalloonTipIcon = 'Info'
57
+ $n.Visible = $true
58
+ $n.ShowBalloonTip(3000)
59
+ Start-Sleep -Milliseconds 3500
60
+ $n.Dispose()
61
+ `.replace(/\n\s*/g, " ");
62
+
63
+ safeSpawn("powershell", ["-NoProfile", "-NonInteractive", "-Command", ps], { windowsHide: true });
64
+ }
65
+
66
+ function notifyMacOS(title: string, body: string): void {
67
+ const script = `display notification "${escapeAS(body)}" with title "${escapeAS(title)}"`;
68
+
69
+ safeSpawn("osascript", ["-e", script]);
70
+ }
71
+
72
+ function notifyLinux(title: string, body: string): void {
73
+ safeSpawn("notify-send", [title, body, "--icon=dialog-information"]);
74
+ }