lightman-agent 1.0.5 → 1.0.7

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 (115) hide show
  1. package/agent.config.template.json +30 -30
  2. package/package.json +52 -52
  3. package/public/assets/index-CcBNCz6h.css +1 -1
  4. package/public/assets/index-D9QHMG8k.js +1 -0
  5. package/public/assets/index-H-8HDl46.js +1 -1
  6. package/public/assets/index-YodeiCia.css +1 -0
  7. package/public/assets/index-legacy-DWtNM8y7.js +41 -0
  8. package/public/assets/museum-map-CwVDA2z1.svg +4182 -0
  9. package/public/assets/polyfills-legacy-DyVYWHbW.js +4 -0
  10. package/public/index.html +7 -2
  11. package/public/templates/custom08/elements/back-button.svg +20 -0
  12. package/public/templates/custom08/elements/base-map-background.svg +37 -0
  13. package/public/templates/custom08/elements/base-map.svg +1191 -0
  14. package/public/templates/custom08/elements/gallery-1-2-3-info-panel.svg +236 -0
  15. package/public/templates/custom08/elements/gallery-4-5-6-7-info-panel.svg +266 -0
  16. package/public/templates/custom08/elements/gallery-8-9-info-panel.svg +274 -0
  17. package/public/templates/custom08/elements/gallery-labels/_nav-map-styles.css +554 -0
  18. package/public/templates/custom08/elements/gallery-labels/_styles.css +556 -0
  19. package/public/templates/custom08/elements/gallery-labels/gallery-1.svg +35 -0
  20. package/public/templates/custom08/elements/gallery-labels/gallery-2.svg +34 -0
  21. package/public/templates/custom08/elements/gallery-labels/gallery-3.svg +34 -0
  22. package/public/templates/custom08/elements/gallery-labels/gallery-4.svg +37 -0
  23. package/public/templates/custom08/elements/gallery-labels/gallery-5.svg +34 -0
  24. package/public/templates/custom08/elements/gallery-labels/gallery-6.svg +34 -0
  25. package/public/templates/custom08/elements/gallery-labels/gallery-7.svg +34 -0
  26. package/public/templates/custom08/elements/gallery-labels/gallery-8.svg +37 -0
  27. package/public/templates/custom08/elements/gallery-labels/gallery-9.svg +34 -0
  28. package/public/templates/custom08/elements/hand-hint.png +0 -0
  29. package/public/templates/custom08/elements/idle-screen-bg.svg +5 -0
  30. package/public/templates/custom08/elements/idle-screen-map.svg +627 -0
  31. package/public/templates/custom08/elements/idle-screen-text.svg +350 -0
  32. package/public/templates/custom08/elements/key-map-1.svg +986 -0
  33. package/public/templates/custom08/elements/key-map-2.svg +1018 -0
  34. package/public/templates/custom08/elements/key-map-3.svg +1019 -0
  35. package/public/templates/custom08/elements/key-map-combined.svg +1001 -0
  36. package/public/templates/custom08/elements/map-highlight-marker.svg +11 -0
  37. package/public/templates/custom08/elements/map-pin-marker.svg +15 -0
  38. package/public/templates/custom08/elements/map-teardrop-star-marker.svg +13 -0
  39. package/public/templates/custom08/elements/nav-circle-galleries-1-3.svg +21 -0
  40. package/public/templates/custom08/elements/nav-circle-galleries-4-7.svg +24 -0
  41. package/public/templates/custom08/elements/nav-circle-galleries-8-9.svg +20 -0
  42. package/public/templates/custom08/elements/section1-map.svg +1435 -0
  43. package/public/templates/custom08/elements/section2-map.svg +1724 -0
  44. package/public/templates/custom08/elements/section3-map.svg +1295 -0
  45. package/public/templates/custom08/fonts/CabinetGrotesk-Variable.ttf +0 -0
  46. package/public/templates/custom08/images/highlights/Screenshot_2026-03-05_at_7.23.12_PM.png +0 -0
  47. package/public/templates/custom08/images/highlights/Screenshot_2026-03-05_at_7.23.56_PM.png +0 -0
  48. package/public/templates/custom08/images/highlights/Screenshot_2026-03-05_at_7.24.24_PM.png +0 -0
  49. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.31.58_PM.jpg +0 -0
  50. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.32.11_PM.jpg +0 -0
  51. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.32.36_PM.jpg +0 -0
  52. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.32.48_PM.jpg +0 -0
  53. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.32.59_PM.jpg +0 -0
  54. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.15_PM.jpg +0 -0
  55. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.27_PM.jpg +0 -0
  56. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.34_PM.jpg +0 -0
  57. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.42_PM.jpg +0 -0
  58. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.50_PM.jpg +0 -0
  59. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.33.58_PM.jpg +0 -0
  60. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.34.04_PM.jpg +0 -0
  61. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.34.11_PM.jpg +0 -0
  62. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.34.20_PM.jpg +0 -0
  63. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.34.57_PM.jpg +0 -0
  64. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.35.03_PM.jpg +0 -0
  65. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.35.16_PM.jpg +0 -0
  66. package/public/templates/custom08/images/highlights/Screenshot_2026-03-24_at_11.35.23_PM.jpg +0 -0
  67. package/public/templates/custom08/images/highlights/prologue-highlight.png +0 -0
  68. package/scripts/guardian.ps1 +75 -75
  69. package/scripts/install-linux.sh +134 -134
  70. package/scripts/install-rpi.sh +117 -117
  71. package/scripts/install-windows.ps1 +505 -505
  72. package/scripts/launch-kiosk.vbs +101 -101
  73. package/scripts/lightman-agent.logrotate +12 -12
  74. package/scripts/lightman-agent.service +38 -38
  75. package/scripts/lightman-shell.bat +107 -107
  76. package/scripts/reinstall-windows.ps1 +26 -26
  77. package/scripts/restore-desktop.ps1 +32 -32
  78. package/scripts/setup.ps1 +116 -116
  79. package/scripts/setup.sh +115 -115
  80. package/scripts/sync-display.mjs +20 -0
  81. package/scripts/uninstall-linux.sh +50 -50
  82. package/scripts/uninstall-windows.ps1 +54 -54
  83. package/src/commands/display.ts +177 -177
  84. package/src/commands/kiosk.ts +113 -113
  85. package/src/commands/maintenance.ts +106 -106
  86. package/src/commands/network.ts +129 -129
  87. package/src/commands/power.ts +163 -163
  88. package/src/commands/rpi.ts +45 -45
  89. package/src/commands/screenshot.ts +166 -166
  90. package/src/commands/serial.ts +17 -17
  91. package/src/commands/update.ts +124 -124
  92. package/src/index.ts +652 -652
  93. package/src/lib/config.ts +69 -69
  94. package/src/lib/identity.ts +40 -40
  95. package/src/lib/logger.ts +137 -137
  96. package/src/lib/platform.ts +10 -10
  97. package/src/lib/rpi.ts +180 -180
  98. package/src/lib/screens.ts +128 -128
  99. package/src/lib/types.ts +176 -176
  100. package/src/services/commands.ts +107 -107
  101. package/src/services/health.ts +161 -161
  102. package/src/services/kiosk.ts +384 -384
  103. package/src/services/localEvents.ts +60 -60
  104. package/src/services/logForwarder.ts +72 -72
  105. package/src/services/multiScreenKiosk.ts +324 -324
  106. package/src/services/oscBridge.ts +186 -186
  107. package/src/services/powerScheduler.ts +260 -260
  108. package/src/services/provisioning.ts +120 -120
  109. package/src/services/serialBridge.ts +230 -230
  110. package/src/services/serviceLauncher.ts +183 -183
  111. package/src/services/staticServer.ts +226 -226
  112. package/src/services/updater.ts +249 -249
  113. package/src/services/watchdog.ts +310 -310
  114. package/src/services/websocket.ts +152 -152
  115. package/tsconfig.json +28 -28
package/src/lib/config.ts CHANGED
@@ -1,69 +1,69 @@
1
- import { readFileSync, existsSync } from 'fs';
2
- import { resolve } from 'path';
3
- import { z } from 'zod';
4
- import type { AgentConfig } from './types.js';
5
-
6
- const kioskSchema = z.object({
7
- browserPath: z.string().default('chromium-browser'),
8
- defaultUrl: z.string().url().default('http://localhost:3401/display'),
9
- extraArgs: z.array(z.string()).default([]),
10
- pollIntervalMs: z.number().int().min(1000).default(10_000),
11
- maxCrashesInWindow: z.number().int().min(1).default(10),
12
- crashWindowMs: z.number().int().min(10_000).default(300_000),
13
- shellMode: z.boolean().default(false),
14
- });
15
-
16
- const screenshotSchema = z.object({
17
- captureCommand: z.string().default('scrot'),
18
- quality: z.number().int().min(1).max(100).default(80),
19
- uploadEndpoint: z.string().default('/api/devices/{deviceId}/screenshot'),
20
- });
21
-
22
- const powerScheduleSchema = z.object({
23
- shutdownCron: z.string().optional(),
24
- startupCron: z.string().optional(),
25
- timezone: z.string().default(Intl.DateTimeFormat().resolvedOptions().timeZone),
26
- shutdownWarningSeconds: z.number().int().min(0).max(600).default(60),
27
- });
28
-
29
- const configSchema = z.object({
30
- serverUrl: z.string().url(),
31
- deviceSlug: z.string().min(1),
32
- healthIntervalMs: z.number().int().min(5000).default(60000),
33
- logLevel: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
34
- logFile: z.string().default('agent.log'),
35
- identityFile: z.string().default('.lightman-identity.json'),
36
- localServices: z.boolean().default(true),
37
- kiosk: kioskSchema.optional(),
38
- screenshot: screenshotSchema.optional(),
39
- powerSchedule: powerScheduleSchema.optional(),
40
- });
41
-
42
- export function loadConfig(configPath?: string): AgentConfig {
43
- const filePath = configPath || resolve(process.cwd(), 'agent.config.json');
44
-
45
- if (!existsSync(filePath)) {
46
- throw new Error(`Config file not found: ${filePath}`);
47
- }
48
-
49
- let raw: Record<string, unknown>;
50
- try {
51
- raw = JSON.parse(readFileSync(filePath, 'utf-8'));
52
- } catch (err) {
53
- throw new Error(`Invalid JSON in config file ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
54
- }
55
-
56
- // Apply environment overrides
57
- const merged = {
58
- ...raw,
59
- ...(process.env.LIGHTMAN_SERVER_URL && { serverUrl: process.env.LIGHTMAN_SERVER_URL }),
60
- ...(process.env.LIGHTMAN_DEVICE_SLUG && { deviceSlug: process.env.LIGHTMAN_DEVICE_SLUG }),
61
- ...(process.env.LIGHTMAN_HEALTH_INTERVAL && { healthIntervalMs: parseInt(process.env.LIGHTMAN_HEALTH_INTERVAL, 10) }),
62
- ...(process.env.LIGHTMAN_LOG_LEVEL && { logLevel: process.env.LIGHTMAN_LOG_LEVEL }),
63
- ...(process.env.LIGHTMAN_LOG_FILE && { logFile: process.env.LIGHTMAN_LOG_FILE }),
64
- ...(process.env.LIGHTMAN_IDENTITY_FILE && { identityFile: process.env.LIGHTMAN_IDENTITY_FILE }),
65
- };
66
-
67
- const result = configSchema.parse(merged);
68
- return result as AgentConfig;
69
- }
1
+ import { readFileSync, existsSync } from 'fs';
2
+ import { resolve } from 'path';
3
+ import { z } from 'zod';
4
+ import type { AgentConfig } from './types.js';
5
+
6
+ const kioskSchema = z.object({
7
+ browserPath: z.string().default('chromium-browser'),
8
+ defaultUrl: z.string().url().default('http://localhost:3401/display'),
9
+ extraArgs: z.array(z.string()).default([]),
10
+ pollIntervalMs: z.number().int().min(1000).default(10_000),
11
+ maxCrashesInWindow: z.number().int().min(1).default(10),
12
+ crashWindowMs: z.number().int().min(10_000).default(300_000),
13
+ shellMode: z.boolean().default(false),
14
+ });
15
+
16
+ const screenshotSchema = z.object({
17
+ captureCommand: z.string().default('scrot'),
18
+ quality: z.number().int().min(1).max(100).default(80),
19
+ uploadEndpoint: z.string().default('/api/devices/{deviceId}/screenshot'),
20
+ });
21
+
22
+ const powerScheduleSchema = z.object({
23
+ shutdownCron: z.string().optional(),
24
+ startupCron: z.string().optional(),
25
+ timezone: z.string().default(Intl.DateTimeFormat().resolvedOptions().timeZone),
26
+ shutdownWarningSeconds: z.number().int().min(0).max(600).default(60),
27
+ });
28
+
29
+ const configSchema = z.object({
30
+ serverUrl: z.string().url(),
31
+ deviceSlug: z.string().min(1),
32
+ healthIntervalMs: z.number().int().min(5000).default(60000),
33
+ logLevel: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
34
+ logFile: z.string().default('agent.log'),
35
+ identityFile: z.string().default('.lightman-identity.json'),
36
+ localServices: z.boolean().default(true),
37
+ kiosk: kioskSchema.optional(),
38
+ screenshot: screenshotSchema.optional(),
39
+ powerSchedule: powerScheduleSchema.optional(),
40
+ });
41
+
42
+ export function loadConfig(configPath?: string): AgentConfig {
43
+ const filePath = configPath || resolve(process.cwd(), 'agent.config.json');
44
+
45
+ if (!existsSync(filePath)) {
46
+ throw new Error(`Config file not found: ${filePath}`);
47
+ }
48
+
49
+ let raw: Record<string, unknown>;
50
+ try {
51
+ raw = JSON.parse(readFileSync(filePath, 'utf-8'));
52
+ } catch (err) {
53
+ throw new Error(`Invalid JSON in config file ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
54
+ }
55
+
56
+ // Apply environment overrides
57
+ const merged = {
58
+ ...raw,
59
+ ...(process.env.LIGHTMAN_SERVER_URL && { serverUrl: process.env.LIGHTMAN_SERVER_URL }),
60
+ ...(process.env.LIGHTMAN_DEVICE_SLUG && { deviceSlug: process.env.LIGHTMAN_DEVICE_SLUG }),
61
+ ...(process.env.LIGHTMAN_HEALTH_INTERVAL && { healthIntervalMs: parseInt(process.env.LIGHTMAN_HEALTH_INTERVAL, 10) }),
62
+ ...(process.env.LIGHTMAN_LOG_LEVEL && { logLevel: process.env.LIGHTMAN_LOG_LEVEL }),
63
+ ...(process.env.LIGHTMAN_LOG_FILE && { logFile: process.env.LIGHTMAN_LOG_FILE }),
64
+ ...(process.env.LIGHTMAN_IDENTITY_FILE && { identityFile: process.env.LIGHTMAN_IDENTITY_FILE }),
65
+ };
66
+
67
+ const result = configSchema.parse(merged);
68
+ return result as AgentConfig;
69
+ }
@@ -1,40 +1,40 @@
1
- import { readFileSync, writeFileSync, existsSync, chmodSync } from 'fs';
2
- import { resolve, dirname } from 'path';
3
- import { mkdirSync } from 'fs';
4
- import type { Identity } from './types.js';
5
-
6
- export function readIdentity(filePath: string): Identity | null {
7
- const fullPath = resolve(process.cwd(), filePath);
8
-
9
- if (!existsSync(fullPath)) {
10
- return null;
11
- }
12
-
13
- try {
14
- const raw = JSON.parse(readFileSync(fullPath, 'utf-8'));
15
- if (raw.deviceId && raw.apiKey) {
16
- return { deviceId: raw.deviceId, apiKey: raw.apiKey };
17
- }
18
- return null;
19
- } catch {
20
- return null;
21
- }
22
- }
23
-
24
- export function writeIdentity(filePath: string, identity: Identity): void {
25
- const fullPath = resolve(process.cwd(), filePath);
26
- const dir = dirname(fullPath);
27
-
28
- if (!existsSync(dir)) {
29
- mkdirSync(dir, { recursive: true });
30
- }
31
-
32
- writeFileSync(fullPath, JSON.stringify(identity, null, 2), { mode: 0o600 });
33
-
34
- // Ensure permissions are correct even if file already existed
35
- try {
36
- chmodSync(fullPath, 0o600);
37
- } catch {
38
- // Ignore permission errors on Windows
39
- }
40
- }
1
+ import { readFileSync, writeFileSync, existsSync, chmodSync } from 'fs';
2
+ import { resolve, dirname } from 'path';
3
+ import { mkdirSync } from 'fs';
4
+ import type { Identity } from './types.js';
5
+
6
+ export function readIdentity(filePath: string): Identity | null {
7
+ const fullPath = resolve(process.cwd(), filePath);
8
+
9
+ if (!existsSync(fullPath)) {
10
+ return null;
11
+ }
12
+
13
+ try {
14
+ const raw = JSON.parse(readFileSync(fullPath, 'utf-8'));
15
+ if (raw.deviceId && raw.apiKey) {
16
+ return { deviceId: raw.deviceId, apiKey: raw.apiKey };
17
+ }
18
+ return null;
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ export function writeIdentity(filePath: string, identity: Identity): void {
25
+ const fullPath = resolve(process.cwd(), filePath);
26
+ const dir = dirname(fullPath);
27
+
28
+ if (!existsSync(dir)) {
29
+ mkdirSync(dir, { recursive: true });
30
+ }
31
+
32
+ writeFileSync(fullPath, JSON.stringify(identity, null, 2), { mode: 0o600 });
33
+
34
+ // Ensure permissions are correct even if file already existed
35
+ try {
36
+ chmodSync(fullPath, 0o600);
37
+ } catch {
38
+ // Ignore permission errors on Windows
39
+ }
40
+ }
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
+ }