lightman-agent 1.0.18 → 1.0.21

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 (52) hide show
  1. package/agent.config.json +22 -23
  2. package/agent.config.template.json +30 -31
  3. package/bin/cms-agent.js +269 -248
  4. package/package.json +1 -1
  5. package/public/assets/index-CcBNCz6h.css +1 -1
  6. package/public/assets/index-D9QHMG8k.js +1 -1
  7. package/public/assets/index-H-8HDl46.js +1 -1
  8. package/public/assets/index-YodeiCia.css +1 -1
  9. package/public/assets/index-legacy-DWtNM8y7.js +41 -41
  10. package/public/assets/polyfills-legacy-DyVYWHbW.js +4 -4
  11. package/scripts/guardian.ps1 +50 -124
  12. package/scripts/install-windows.ps1 +60 -116
  13. package/scripts/lightman-agent.logrotate +12 -12
  14. package/scripts/lightman-agent.service +38 -38
  15. package/scripts/reinstall-windows.ps1 +26 -26
  16. package/scripts/restore-desktop.ps1 +32 -32
  17. package/scripts/setup.ps1 +17 -22
  18. package/scripts/sync-display.mjs +20 -20
  19. package/scripts/uninstall-windows.ps1 +54 -54
  20. package/src/commands/display.ts +177 -177
  21. package/src/commands/kiosk.ts +113 -113
  22. package/src/commands/maintenance.ts +106 -106
  23. package/src/commands/network.ts +129 -129
  24. package/src/commands/power.ts +163 -163
  25. package/src/commands/rpi.ts +45 -45
  26. package/src/commands/screenshot.ts +166 -166
  27. package/src/commands/serial.ts +17 -17
  28. package/src/commands/update.ts +124 -124
  29. package/src/index.ts +173 -90
  30. package/src/lib/config.ts +2 -3
  31. package/src/lib/identity.ts +40 -40
  32. package/src/lib/logger.ts +137 -137
  33. package/src/lib/platform.ts +10 -10
  34. package/src/lib/rpi.ts +180 -180
  35. package/src/lib/screenMap.ts +135 -0
  36. package/src/lib/screens.ts +128 -128
  37. package/src/lib/types.ts +176 -177
  38. package/src/services/commands.ts +107 -107
  39. package/src/services/health.ts +161 -161
  40. package/src/services/localEvents.ts +60 -60
  41. package/src/services/logForwarder.ts +72 -72
  42. package/src/services/multiScreenKiosk.ts +116 -83
  43. package/src/services/oscBridge.ts +186 -186
  44. package/src/services/powerScheduler.ts +260 -260
  45. package/src/services/provisioning.ts +120 -122
  46. package/src/services/serialBridge.ts +230 -230
  47. package/src/services/serviceLauncher.ts +183 -183
  48. package/src/services/staticServer.ts +226 -226
  49. package/src/services/updater.ts +249 -249
  50. package/src/services/watchdog.ts +310 -310
  51. package/src/services/websocket.ts +152 -152
  52. package/tsconfig.json +28 -28
package/src/lib/logger.ts CHANGED
@@ -1,137 +1,137 @@
1
- import { appendFileSync, existsSync, mkdirSync, statSync, renameSync, unlinkSync } from 'fs';
2
- import { dirname } from 'path';
3
- import type { LogEntry } from './types.js';
4
-
5
- type LogLevel = 'debug' | 'info' | 'warn' | 'error';
6
-
7
- const LEVEL_ORDER: Record<LogLevel, number> = {
8
- debug: 0,
9
- info: 1,
10
- warn: 2,
11
- error: 3,
12
- };
13
-
14
- const LEVEL_LABELS: Record<LogLevel, string> = {
15
- debug: 'DEBUG',
16
- info: 'INFO ',
17
- warn: 'WARN ',
18
- error: 'ERROR',
19
- };
20
-
21
- const MAX_LOG_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
22
- const MAX_ROTATED_FILES = 3; // Keep agent.log.1, .2, .3
23
-
24
- export class Logger {
25
- private level: LogLevel;
26
- private logFile: string | null;
27
- private listeners: Array<(entry: LogEntry) => void> = [];
28
- private writesSinceRotateCheck = 0;
29
-
30
- constructor(level: LogLevel = 'info', logFile?: string) {
31
- this.level = level;
32
- this.logFile = logFile || null;
33
-
34
- if (this.logFile) {
35
- const dir = dirname(this.logFile);
36
- if (!existsSync(dir)) {
37
- mkdirSync(dir, { recursive: true });
38
- }
39
- }
40
- }
41
-
42
- onLog(fn: (entry: LogEntry) => void): void {
43
- this.listeners = [...this.listeners, fn];
44
- }
45
-
46
- setLevel(level: LogLevel): void {
47
- this.level = level;
48
- }
49
-
50
- debug(message: string, ...args: unknown[]): void {
51
- this.log('debug', message, ...args);
52
- }
53
-
54
- info(message: string, ...args: unknown[]): void {
55
- this.log('info', message, ...args);
56
- }
57
-
58
- warn(message: string, ...args: unknown[]): void {
59
- this.log('warn', message, ...args);
60
- }
61
-
62
- error(message: string, ...args: unknown[]): void {
63
- this.log('error', message, ...args);
64
- }
65
-
66
- private log(level: LogLevel, message: string, ...args: unknown[]): void {
67
- if (LEVEL_ORDER[level] < LEVEL_ORDER[this.level]) {
68
- return;
69
- }
70
-
71
- const timestamp = new Date().toISOString();
72
- const label = LEVEL_LABELS[level];
73
- const formatted = `[${timestamp}] [${label}] ${message}`;
74
-
75
- // Console output
76
- if (level === 'error') {
77
- console.error(formatted, ...args);
78
- } else if (level === 'warn') {
79
- console.warn(formatted, ...args);
80
- } else {
81
- console.log(formatted, ...args);
82
- }
83
-
84
- // File output (with rotation)
85
- if (this.logFile) {
86
- try {
87
- const extra = args.length > 0 ? ' ' + args.map(a => JSON.stringify(a)).join(' ') : '';
88
- appendFileSync(this.logFile, formatted + extra + '\n');
89
-
90
- // Check rotation every 100 writes to avoid stat() on every log line
91
- this.writesSinceRotateCheck++;
92
- if (this.writesSinceRotateCheck >= 100) {
93
- this.writesSinceRotateCheck = 0;
94
- this.rotateIfNeeded();
95
- }
96
- } catch {
97
- // Silently fail file writes to avoid recursive errors
98
- }
99
- }
100
-
101
- // Notify listeners
102
- const entry: LogEntry = { timestamp, level, message, source: 'agent' };
103
- for (const listener of this.listeners) {
104
- try {
105
- listener(entry);
106
- } catch {
107
- // Prevent listener errors from breaking logging
108
- }
109
- }
110
- }
111
-
112
- private rotateIfNeeded(): void {
113
- if (!this.logFile) return;
114
- try {
115
- const stat = statSync(this.logFile);
116
- if (stat.size < MAX_LOG_SIZE_BYTES) return;
117
-
118
- // Rotate: agent.log.3 → delete, agent.log.2 → .3, agent.log.1 → .2, agent.log → .1
119
- for (let i = MAX_ROTATED_FILES; i >= 1; i--) {
120
- const src = i === 1 ? this.logFile : `${this.logFile}.${i - 1}`;
121
- const dst = `${this.logFile}.${i}`;
122
- try {
123
- if (i === MAX_ROTATED_FILES && existsSync(dst)) {
124
- unlinkSync(dst);
125
- }
126
- if (existsSync(src)) {
127
- renameSync(src, dst);
128
- }
129
- } catch {
130
- // Best effort rotation
131
- }
132
- }
133
- } catch {
134
- // stat failed, skip rotation
135
- }
136
- }
137
- }
1
+ import { appendFileSync, existsSync, mkdirSync, statSync, renameSync, unlinkSync } from 'fs';
2
+ import { dirname } from 'path';
3
+ import type { LogEntry } from './types.js';
4
+
5
+ type LogLevel = 'debug' | 'info' | 'warn' | 'error';
6
+
7
+ const LEVEL_ORDER: Record<LogLevel, number> = {
8
+ debug: 0,
9
+ info: 1,
10
+ warn: 2,
11
+ error: 3,
12
+ };
13
+
14
+ const LEVEL_LABELS: Record<LogLevel, string> = {
15
+ debug: 'DEBUG',
16
+ info: 'INFO ',
17
+ warn: 'WARN ',
18
+ error: 'ERROR',
19
+ };
20
+
21
+ const MAX_LOG_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
22
+ const MAX_ROTATED_FILES = 3; // Keep agent.log.1, .2, .3
23
+
24
+ export class Logger {
25
+ private level: LogLevel;
26
+ private logFile: string | null;
27
+ private listeners: Array<(entry: LogEntry) => void> = [];
28
+ private writesSinceRotateCheck = 0;
29
+
30
+ constructor(level: LogLevel = 'info', logFile?: string) {
31
+ this.level = level;
32
+ this.logFile = logFile || null;
33
+
34
+ if (this.logFile) {
35
+ const dir = dirname(this.logFile);
36
+ if (!existsSync(dir)) {
37
+ mkdirSync(dir, { recursive: true });
38
+ }
39
+ }
40
+ }
41
+
42
+ onLog(fn: (entry: LogEntry) => void): void {
43
+ this.listeners = [...this.listeners, fn];
44
+ }
45
+
46
+ setLevel(level: LogLevel): void {
47
+ this.level = level;
48
+ }
49
+
50
+ debug(message: string, ...args: unknown[]): void {
51
+ this.log('debug', message, ...args);
52
+ }
53
+
54
+ info(message: string, ...args: unknown[]): void {
55
+ this.log('info', message, ...args);
56
+ }
57
+
58
+ warn(message: string, ...args: unknown[]): void {
59
+ this.log('warn', message, ...args);
60
+ }
61
+
62
+ error(message: string, ...args: unknown[]): void {
63
+ this.log('error', message, ...args);
64
+ }
65
+
66
+ private log(level: LogLevel, message: string, ...args: unknown[]): void {
67
+ if (LEVEL_ORDER[level] < LEVEL_ORDER[this.level]) {
68
+ return;
69
+ }
70
+
71
+ const timestamp = new Date().toISOString();
72
+ const label = LEVEL_LABELS[level];
73
+ const formatted = `[${timestamp}] [${label}] ${message}`;
74
+
75
+ // Console output
76
+ if (level === 'error') {
77
+ console.error(formatted, ...args);
78
+ } else if (level === 'warn') {
79
+ console.warn(formatted, ...args);
80
+ } else {
81
+ console.log(formatted, ...args);
82
+ }
83
+
84
+ // File output (with rotation)
85
+ if (this.logFile) {
86
+ try {
87
+ const extra = args.length > 0 ? ' ' + args.map(a => JSON.stringify(a)).join(' ') : '';
88
+ appendFileSync(this.logFile, formatted + extra + '\n');
89
+
90
+ // Check rotation every 100 writes to avoid stat() on every log line
91
+ this.writesSinceRotateCheck++;
92
+ if (this.writesSinceRotateCheck >= 100) {
93
+ this.writesSinceRotateCheck = 0;
94
+ this.rotateIfNeeded();
95
+ }
96
+ } catch {
97
+ // Silently fail file writes to avoid recursive errors
98
+ }
99
+ }
100
+
101
+ // Notify listeners
102
+ const entry: LogEntry = { timestamp, level, message, source: 'agent' };
103
+ for (const listener of this.listeners) {
104
+ try {
105
+ listener(entry);
106
+ } catch {
107
+ // Prevent listener errors from breaking logging
108
+ }
109
+ }
110
+ }
111
+
112
+ private rotateIfNeeded(): void {
113
+ if (!this.logFile) return;
114
+ try {
115
+ const stat = statSync(this.logFile);
116
+ if (stat.size < MAX_LOG_SIZE_BYTES) return;
117
+
118
+ // Rotate: agent.log.3 → delete, agent.log.2 → .3, agent.log.1 → .2, agent.log → .1
119
+ for (let i = MAX_ROTATED_FILES; i >= 1; i--) {
120
+ const src = i === 1 ? this.logFile : `${this.logFile}.${i - 1}`;
121
+ const dst = `${this.logFile}.${i}`;
122
+ try {
123
+ if (i === MAX_ROTATED_FILES && existsSync(dst)) {
124
+ unlinkSync(dst);
125
+ }
126
+ if (existsSync(src)) {
127
+ renameSync(src, dst);
128
+ }
129
+ } catch {
130
+ // Best effort rotation
131
+ }
132
+ }
133
+ } catch {
134
+ // stat failed, skip rotation
135
+ }
136
+ }
137
+ }
@@ -1,10 +1,10 @@
1
- import { platform } from 'os';
2
-
3
- export type Platform = 'linux' | 'windows' | 'darwin';
4
-
5
- export function getPlatform(): Platform {
6
- const p = platform();
7
- if (p === 'win32') return 'windows';
8
- if (p === 'darwin') return 'darwin';
9
- return 'linux';
10
- }
1
+ import { platform } from 'os';
2
+
3
+ export type Platform = 'linux' | 'windows' | 'darwin';
4
+
5
+ export function getPlatform(): Platform {
6
+ const p = platform();
7
+ if (p === 'win32') return 'windows';
8
+ if (p === 'darwin') return 'darwin';
9
+ return 'linux';
10
+ }
package/src/lib/rpi.ts CHANGED
@@ -1,180 +1,180 @@
1
- import { execFileSync } from 'child_process';
2
- import { existsSync, readFileSync, openSync, writeSync, closeSync } from 'fs';
3
-
4
- // --- RPi Detection ---
5
-
6
- let isRpiCached: boolean | null = null;
7
-
8
- /**
9
- * Detect if this machine is a Raspberry Pi by checking /proc/device-tree/model.
10
- */
11
- export function isRaspberryPi(): boolean {
12
- if (isRpiCached !== null) return isRpiCached;
13
- try {
14
- if (!existsSync('/proc/device-tree/model')) {
15
- isRpiCached = false;
16
- return false;
17
- }
18
- const model = readFileSync('/proc/device-tree/model', 'utf-8');
19
- isRpiCached = model.toLowerCase().includes('raspberry pi');
20
- return isRpiCached;
21
- } catch {
22
- isRpiCached = false;
23
- return false;
24
- }
25
- }
26
-
27
- /** Reset detection cache (for testing). */
28
- export function resetRpiCache(): void {
29
- isRpiCached = null;
30
- }
31
-
32
- // --- RPi Model Info ---
33
-
34
- export interface RpiInfo {
35
- model: string | null;
36
- serial: string | null;
37
- revision: string | null;
38
- }
39
-
40
- export function getRpiInfo(): RpiInfo {
41
- const info: RpiInfo = { model: null, serial: null, revision: null };
42
- try {
43
- info.model = readFileSync('/proc/device-tree/model', 'utf-8').replace(/\0/g, '').trim();
44
- } catch { /* not available */ }
45
- try {
46
- const cpuinfo = readFileSync('/proc/cpuinfo', 'utf-8');
47
- const serialMatch = cpuinfo.match(/Serial\s*:\s*([0-9a-fA-F]+)/);
48
- if (serialMatch) info.serial = serialMatch[1];
49
- const revisionMatch = cpuinfo.match(/Revision\s*:\s*([0-9a-fA-F]+)/);
50
- if (revisionMatch) info.revision = revisionMatch[1];
51
- } catch { /* not available */ }
52
- return info;
53
- }
54
-
55
- // --- GPU Temperature ---
56
-
57
- /**
58
- * Read GPU temperature via vcgencmd. Returns null if not available.
59
- */
60
- export function getGpuTemp(): number | null {
61
- try {
62
- const output = execFileSync('vcgencmd', ['measure_temp'], {
63
- timeout: 3000,
64
- stdio: 'pipe',
65
- }).toString();
66
- // Output format: temp=42.8'C
67
- const match = output.match(/temp=([\d.]+)/);
68
- if (match) return Math.round(parseFloat(match[1]) * 10) / 10;
69
- return null;
70
- } catch {
71
- return null;
72
- }
73
- }
74
-
75
- // --- Throttle Status ---
76
-
77
- /**
78
- * Read throttle/undervoltage status via vcgencmd. Returns null if not available.
79
- * See: https://www.raspberrypi.com/documentation/computers/os.html#get_throttled
80
- *
81
- * Bit meanings:
82
- * 0: Under-voltage detected
83
- * 1: Arm frequency capped
84
- * 2: Currently throttled
85
- * 3: Soft temperature limit active
86
- * 16: Under-voltage has occurred
87
- * 17: Arm frequency capping has occurred
88
- * 18: Throttling has occurred
89
- * 19: Soft temperature limit has occurred
90
- */
91
- export function getThrottled(): number | null {
92
- try {
93
- const output = execFileSync('vcgencmd', ['get_throttled'], {
94
- timeout: 3000,
95
- stdio: 'pipe',
96
- }).toString();
97
- // Output format: throttled=0x0
98
- const match = output.match(/throttled=(0x[0-9a-fA-F]+)/);
99
- if (match) return parseInt(match[1], 16);
100
- return null;
101
- } catch {
102
- return null;
103
- }
104
- }
105
-
106
- // --- SD Card Read-Only Check ---
107
-
108
- /**
109
- * Check if the root filesystem is mounted read-only.
110
- */
111
- export function isSdCardReadOnly(): boolean {
112
- try {
113
- const mounts = readFileSync('/proc/mounts', 'utf-8');
114
- const rootLine = mounts.split('\n').find((line) => {
115
- const parts = line.split(' ');
116
- return parts[1] === '/';
117
- });
118
- if (!rootLine) return false;
119
- const options = rootLine.split(' ')[3] || '';
120
- return options.split(',').includes('ro');
121
- } catch {
122
- return false;
123
- }
124
- }
125
-
126
- // --- Hardware Watchdog ---
127
-
128
- let watchdogFd: number | null = null;
129
- let watchdogTimer: NodeJS.Timeout | null = null;
130
- const WATCHDOG_DEVICE = '/dev/watchdog';
131
- const WATCHDOG_INTERVAL_MS = 10_000; // Pet every 10 seconds
132
-
133
- /**
134
- * Start the hardware watchdog. Writes to /dev/watchdog every 10s.
135
- * If the agent dies, the kernel will reboot after ~15s.
136
- * Returns true if started, false if not available.
137
- */
138
- export function startWatchdog(): boolean {
139
- if (watchdogFd !== null) return true; // Already running
140
- try {
141
- if (!existsSync(WATCHDOG_DEVICE)) return false;
142
- watchdogFd = openSync(WATCHDOG_DEVICE, 'w');
143
- // Pet immediately
144
- petWatchdog();
145
- // Set up periodic petting
146
- watchdogTimer = setInterval(petWatchdog, WATCHDOG_INTERVAL_MS);
147
- return true;
148
- } catch {
149
- watchdogFd = null;
150
- return false;
151
- }
152
- }
153
-
154
- /**
155
- * Stop the hardware watchdog gracefully.
156
- * Writes 'V' (magic close character) to disable the watchdog before closing.
157
- */
158
- export function stopWatchdog(): void {
159
- if (watchdogTimer) {
160
- clearInterval(watchdogTimer);
161
- watchdogTimer = null;
162
- }
163
- if (watchdogFd !== null) {
164
- try {
165
- // Magic close character 'V' disables the watchdog
166
- writeSync(watchdogFd, 'V');
167
- closeSync(watchdogFd);
168
- } catch { /* ignore close errors */ }
169
- watchdogFd = null;
170
- }
171
- }
172
-
173
- function petWatchdog(): void {
174
- if (watchdogFd === null) return;
175
- try {
176
- writeSync(watchdogFd, '1');
177
- } catch {
178
- // If write fails, watchdog will trigger reboot — this is by design
179
- }
180
- }
1
+ import { execFileSync } from 'child_process';
2
+ import { existsSync, readFileSync, openSync, writeSync, closeSync } from 'fs';
3
+
4
+ // --- RPi Detection ---
5
+
6
+ let isRpiCached: boolean | null = null;
7
+
8
+ /**
9
+ * Detect if this machine is a Raspberry Pi by checking /proc/device-tree/model.
10
+ */
11
+ export function isRaspberryPi(): boolean {
12
+ if (isRpiCached !== null) return isRpiCached;
13
+ try {
14
+ if (!existsSync('/proc/device-tree/model')) {
15
+ isRpiCached = false;
16
+ return false;
17
+ }
18
+ const model = readFileSync('/proc/device-tree/model', 'utf-8');
19
+ isRpiCached = model.toLowerCase().includes('raspberry pi');
20
+ return isRpiCached;
21
+ } catch {
22
+ isRpiCached = false;
23
+ return false;
24
+ }
25
+ }
26
+
27
+ /** Reset detection cache (for testing). */
28
+ export function resetRpiCache(): void {
29
+ isRpiCached = null;
30
+ }
31
+
32
+ // --- RPi Model Info ---
33
+
34
+ export interface RpiInfo {
35
+ model: string | null;
36
+ serial: string | null;
37
+ revision: string | null;
38
+ }
39
+
40
+ export function getRpiInfo(): RpiInfo {
41
+ const info: RpiInfo = { model: null, serial: null, revision: null };
42
+ try {
43
+ info.model = readFileSync('/proc/device-tree/model', 'utf-8').replace(/\0/g, '').trim();
44
+ } catch { /* not available */ }
45
+ try {
46
+ const cpuinfo = readFileSync('/proc/cpuinfo', 'utf-8');
47
+ const serialMatch = cpuinfo.match(/Serial\s*:\s*([0-9a-fA-F]+)/);
48
+ if (serialMatch) info.serial = serialMatch[1];
49
+ const revisionMatch = cpuinfo.match(/Revision\s*:\s*([0-9a-fA-F]+)/);
50
+ if (revisionMatch) info.revision = revisionMatch[1];
51
+ } catch { /* not available */ }
52
+ return info;
53
+ }
54
+
55
+ // --- GPU Temperature ---
56
+
57
+ /**
58
+ * Read GPU temperature via vcgencmd. Returns null if not available.
59
+ */
60
+ export function getGpuTemp(): number | null {
61
+ try {
62
+ const output = execFileSync('vcgencmd', ['measure_temp'], {
63
+ timeout: 3000,
64
+ stdio: 'pipe',
65
+ }).toString();
66
+ // Output format: temp=42.8'C
67
+ const match = output.match(/temp=([\d.]+)/);
68
+ if (match) return Math.round(parseFloat(match[1]) * 10) / 10;
69
+ return null;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ // --- Throttle Status ---
76
+
77
+ /**
78
+ * Read throttle/undervoltage status via vcgencmd. Returns null if not available.
79
+ * See: https://www.raspberrypi.com/documentation/computers/os.html#get_throttled
80
+ *
81
+ * Bit meanings:
82
+ * 0: Under-voltage detected
83
+ * 1: Arm frequency capped
84
+ * 2: Currently throttled
85
+ * 3: Soft temperature limit active
86
+ * 16: Under-voltage has occurred
87
+ * 17: Arm frequency capping has occurred
88
+ * 18: Throttling has occurred
89
+ * 19: Soft temperature limit has occurred
90
+ */
91
+ export function getThrottled(): number | null {
92
+ try {
93
+ const output = execFileSync('vcgencmd', ['get_throttled'], {
94
+ timeout: 3000,
95
+ stdio: 'pipe',
96
+ }).toString();
97
+ // Output format: throttled=0x0
98
+ const match = output.match(/throttled=(0x[0-9a-fA-F]+)/);
99
+ if (match) return parseInt(match[1], 16);
100
+ return null;
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ // --- SD Card Read-Only Check ---
107
+
108
+ /**
109
+ * Check if the root filesystem is mounted read-only.
110
+ */
111
+ export function isSdCardReadOnly(): boolean {
112
+ try {
113
+ const mounts = readFileSync('/proc/mounts', 'utf-8');
114
+ const rootLine = mounts.split('\n').find((line) => {
115
+ const parts = line.split(' ');
116
+ return parts[1] === '/';
117
+ });
118
+ if (!rootLine) return false;
119
+ const options = rootLine.split(' ')[3] || '';
120
+ return options.split(',').includes('ro');
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
125
+
126
+ // --- Hardware Watchdog ---
127
+
128
+ let watchdogFd: number | null = null;
129
+ let watchdogTimer: NodeJS.Timeout | null = null;
130
+ const WATCHDOG_DEVICE = '/dev/watchdog';
131
+ const WATCHDOG_INTERVAL_MS = 10_000; // Pet every 10 seconds
132
+
133
+ /**
134
+ * Start the hardware watchdog. Writes to /dev/watchdog every 10s.
135
+ * If the agent dies, the kernel will reboot after ~15s.
136
+ * Returns true if started, false if not available.
137
+ */
138
+ export function startWatchdog(): boolean {
139
+ if (watchdogFd !== null) return true; // Already running
140
+ try {
141
+ if (!existsSync(WATCHDOG_DEVICE)) return false;
142
+ watchdogFd = openSync(WATCHDOG_DEVICE, 'w');
143
+ // Pet immediately
144
+ petWatchdog();
145
+ // Set up periodic petting
146
+ watchdogTimer = setInterval(petWatchdog, WATCHDOG_INTERVAL_MS);
147
+ return true;
148
+ } catch {
149
+ watchdogFd = null;
150
+ return false;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Stop the hardware watchdog gracefully.
156
+ * Writes 'V' (magic close character) to disable the watchdog before closing.
157
+ */
158
+ export function stopWatchdog(): void {
159
+ if (watchdogTimer) {
160
+ clearInterval(watchdogTimer);
161
+ watchdogTimer = null;
162
+ }
163
+ if (watchdogFd !== null) {
164
+ try {
165
+ // Magic close character 'V' disables the watchdog
166
+ writeSync(watchdogFd, 'V');
167
+ closeSync(watchdogFd);
168
+ } catch { /* ignore close errors */ }
169
+ watchdogFd = null;
170
+ }
171
+ }
172
+
173
+ function petWatchdog(): void {
174
+ if (watchdogFd === null) return;
175
+ try {
176
+ writeSync(watchdogFd, '1');
177
+ } catch {
178
+ // If write fails, watchdog will trigger reboot — this is by design
179
+ }
180
+ }