lightman-agent 1.0.12 → 1.0.14

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.
@@ -1,414 +1,395 @@
1
- import { spawn, execSync } from 'child_process';
2
- import { writeFileSync, readFileSync, existsSync } from 'fs';
3
- import { basename } from 'path';
4
- import type { ChildProcess } from 'child_process';
5
- import type { KioskConfig, KioskStatus } from '../lib/types.js';
6
- import type { Logger } from '../lib/logger.js';
7
-
8
- /**
9
- * URL sidecar file — used in shell mode so the shell BAT reads the current
10
- * target URL before launching Chrome. Agent writes, shell reads.
11
- */
12
- const URL_SIDECAR_FILE = process.platform === 'win32'
13
- ? 'C:\\ProgramData\\Lightman\\kiosk-url.txt'
14
- : '/tmp/lightman-kiosk-url.txt';
15
-
16
- export class KioskManager {
17
- private config: KioskConfig;
18
- private logger: Logger;
19
- private shellMode: boolean;
20
- private process: ChildProcess | null = null;
21
- private currentUrl: string | null = null;
22
- private startedAt: number | null = null;
23
- private crashTimestamps: number[] = [];
24
- private crashLoopDetected = false;
25
- private restarting = false;
26
- private pollTimer: NodeJS.Timeout | null = null;
27
-
28
- constructor(config: KioskConfig, logger: Logger) {
29
- this.config = config;
30
- this.logger = logger;
31
- this.shellMode = config.shellMode ?? false;
32
-
33
- if (this.shellMode) {
34
- this.logger.info('KioskManager: shell mode enabled - Chrome lifecycle managed by Windows shell');
35
- // Shell BAT reads slug directly from agent.config.json - no sidecar needed
36
- }
37
- }
38
-
39
- /**
40
- * Launch the kiosk browser.
41
- *
42
- * Standard mode: spawns Chrome as a child process.
43
- * Shell mode: writes URL to sidecar file. If Chrome isn't running, kills
44
- * any stale instance (the shell's infinite loop will relaunch it).
45
- * If Chrome IS running, kills it so the shell relaunches with new URL.
46
- */
47
- async launch(url?: string): Promise<KioskStatus> {
48
- const targetUrl = url || this.config.defaultUrl;
49
- this.currentUrl = targetUrl;
50
-
51
- if (this.shellMode) {
52
- return this.shellLaunch(targetUrl);
53
- }
54
- return this.standardLaunch(targetUrl);
55
- }
56
-
57
- async kill(): Promise<void> {
58
- if (this.shellMode) {
59
- // In shell mode, we just kill Chrome — the shell BAT will relaunch it
60
- this.killAllBrowser();
61
- return;
62
- }
63
-
64
- this.stopPoll();
65
- if (!this.process) {
66
- return;
67
- }
68
-
69
- const proc = this.process;
70
- this.process = null;
71
-
72
- return new Promise<void>((resolve) => {
73
- let killed = false;
74
-
75
- const onExit = () => {
76
- if (killed) return;
77
- killed = true;
78
- clearTimeout(forceKillTimer);
79
- resolve();
80
- };
81
-
82
- const forceKillTimer = setTimeout(() => {
83
- if (!killed) {
84
- this.logger.warn('Kiosk did not exit after SIGTERM, sending SIGKILL');
85
- try {
86
- proc.kill('SIGKILL');
87
- } catch {
88
- // Process may already be dead
89
- }
90
- }
91
- }, 5_000);
92
-
93
- // Remove the crash handler so kill doesn't trigger auto-restart
94
- proc.removeAllListeners('exit');
95
- proc.once('exit', onExit);
96
-
97
- try {
98
- proc.kill('SIGTERM');
99
- } catch {
100
- // Process already dead
101
- onExit();
102
- }
103
- });
104
- }
105
-
106
- async navigate(url: string): Promise<void> {
107
- this.logger.info(`Navigating kiosk to: ${url}`);
108
-
109
- if (this.shellMode) {
110
- // Write new URL → kill Chrome → shell relaunches with new URL
111
- this.writeUrlSidecar(url);
112
- this.currentUrl = url;
113
- this.killAllBrowser();
114
- return;
115
- }
116
-
117
- await this.kill();
118
- await this.launch(url);
119
- }
120
-
121
- async restart(): Promise<KioskStatus> {
122
- this.logger.info('Restarting kiosk');
123
-
124
- if (this.shellMode) {
125
- // Just kill Chrome — shell relaunches it with same URL from sidecar
126
- this.killAllBrowser();
127
- // Give shell time to relaunch
128
- await new Promise((r) => setTimeout(r, 5_000));
129
- return this.getStatus();
130
- }
131
-
132
- this.restarting = true;
133
- const url = this.currentUrl;
134
- await this.kill();
135
- return this.launch(url || undefined);
136
- }
137
-
138
- getStatus(): KioskStatus {
139
- if (this.shellMode) {
140
- return this.getShellModeStatus();
141
- }
142
-
143
- const running = this.process !== null && this.process.exitCode === null;
144
- return {
145
- running,
146
- pid: running && this.process ? this.process.pid ?? null : null,
147
- url: this.currentUrl,
148
- crashCount: this.crashTimestamps.length,
149
- crashLoopDetected: this.crashLoopDetected,
150
- uptimeMs: running && this.startedAt ? Date.now() - this.startedAt : null,
151
- };
152
- }
153
-
154
- destroy(): void {
155
- this.stopPoll();
156
- if (this.process) {
157
- try {
158
- this.process.removeAllListeners();
159
- this.process.kill('SIGKILL');
160
- } catch {
161
- // Process may already be dead
162
- }
163
- this.process = null;
164
- }
165
- // In shell mode, do NOT kill Chrome on agent shutdown — the shell keeps it alive
166
- }
167
-
168
- // =====================================================================
169
- // Shell Mode Methods
170
- // =====================================================================
171
-
172
- private async shellLaunch(targetUrl: string): Promise<KioskStatus> {
173
- this.currentUrl = targetUrl;
174
- // Keep shell sidecar updated so shell BAT can launch with auth query params.
175
- this.writeUrlSidecar(targetUrl);
176
-
177
- // Shell mode: Chrome is managed by lightman-shell.bat.
178
- // Shell prefers sidecar URL (if present), then falls back to slug in config.
179
- if (this.isBrowserRunning()) {
180
- this.logger.info('Shell mode: browser already running. Restarting once to apply sidecar URL.');
181
- this.killAllBrowser();
182
- } else {
183
- this.logger.info('Shell mode: browser not running. Shell BAT will launch it.');
184
- }
185
-
186
- this.startedAt = this.startedAt || Date.now();
187
- return this.getStatus();
188
- }
189
-
190
- private getShellModeStatus(): KioskStatus {
191
- const running = this.isBrowserRunning();
192
- return {
193
- running,
194
- pid: running ? this.getBrowserPid() : null,
195
- url: this.currentUrl || this.readUrlSidecar(),
196
- crashCount: 0, // Shell handles crash recovery, not us
197
- crashLoopDetected: false,
198
- uptimeMs: running && this.startedAt ? Date.now() - this.startedAt : null,
199
- };
200
- }
201
-
202
- /** Write the target URL to the sidecar file that the shell BAT reads */
203
- private writeUrlSidecar(url: string): void {
204
- try {
205
- writeFileSync(URL_SIDECAR_FILE, url, 'utf-8');
206
- } catch (err) {
207
- this.logger.error('Failed to write URL sidecar:', err instanceof Error ? err.message : String(err));
208
- }
209
- }
210
-
211
- /** Read the current URL from sidecar file */
212
- private readUrlSidecar(): string | null {
213
- try {
214
- if (existsSync(URL_SIDECAR_FILE)) {
215
- return readFileSync(URL_SIDECAR_FILE, 'utf-8').trim();
216
- }
217
- } catch {
218
- // Best effort
219
- }
220
- return null;
221
- }
222
-
223
- /** Check if the configured kiosk browser process is running */
224
- private isBrowserRunning(): boolean {
225
- try {
226
- if (process.platform === 'win32') {
227
- const browserExe = this.getWindowsBrowserExecutableName();
228
- const result = execSync(`tasklist /FI "IMAGENAME eq ${browserExe}" /NH`, {
229
- encoding: 'utf-8',
230
- timeout: 5_000,
231
- stdio: ['pipe', 'pipe', 'ignore'],
232
- });
233
- return result.toLowerCase().includes(browserExe.toLowerCase());
234
- } else {
235
- execSync('pgrep -x chrome || pgrep -x chromium-browser', {
236
- stdio: 'ignore',
237
- timeout: 5_000,
238
- });
239
- return true;
240
- }
241
- } catch {
242
- return false;
243
- }
244
- }
245
-
246
- /** Get PID of main kiosk browser process */
247
- private getBrowserPid(): number | null {
248
- try {
249
- if (process.platform === 'win32') {
250
- const browserExe = this.getWindowsBrowserExecutableName();
251
- const result = execSync(
252
- `wmic process where "name='${browserExe}' and CommandLine like '%--kiosk%'" get ProcessId /format:value`,
253
- { encoding: 'utf-8', timeout: 5_000, stdio: ['pipe', 'pipe', 'ignore'] }
254
- );
255
- const match = result.match(/ProcessId=(\d+)/);
256
- return match ? parseInt(match[1], 10) : null;
257
- }
258
- } catch {
259
- // Best effort
260
- }
261
- return null;
262
- }
263
-
264
- private getWindowsBrowserExecutableName(): string {
265
- const browserPath = this.config.browserPath?.trim();
266
- if (!browserPath) {
267
- return 'chrome.exe';
268
- }
269
-
270
- const fileName = basename(browserPath).trim().toLowerCase();
271
- if (!fileName) {
272
- return 'chrome.exe';
273
- }
274
-
275
- return fileName.endsWith('.exe') ? fileName : `${fileName}.exe`;
276
- }
277
-
278
- // =====================================================================
279
- // Standard Mode Methods (original behavior)
280
- // =====================================================================
281
-
282
- private async standardLaunch(targetUrl: string): Promise<KioskStatus> {
283
- // Kill existing process if running
284
- if (this.process) {
285
- await this.kill();
286
- }
287
-
288
- // Kill any leftover Chrome kiosk instances
289
- this.killAllBrowser();
290
-
291
- // Delay to let Chrome fully release profile lock
292
- await new Promise((r) => setTimeout(r, 2_000));
293
-
294
- const args = [
295
- '--kiosk',
296
- '--noerrdialogs',
297
- '--disable-infobars',
298
- '--disable-session-crashed-bubble',
299
- '--no-first-run',
300
- '--no-default-browser-check',
301
- ...this.config.extraArgs,
302
- targetUrl,
303
- ];
304
-
305
- this.logger.info(`Launching kiosk: ${this.config.browserPath} → ${targetUrl}`);
306
-
307
- this.process = spawn(this.config.browserPath, args, {
308
- stdio: 'ignore',
309
- detached: true,
310
- });
311
-
312
- // Unref so the agent process isn't held open by Chrome
313
- this.process.unref();
314
-
315
- this.currentUrl = targetUrl;
316
- this.startedAt = Date.now();
317
- this.crashLoopDetected = false;
318
-
319
- this.process.on('exit', (code) => {
320
- this.handleCrash(code);
321
- });
322
-
323
- this.process.on('error', (err) => {
324
- this.logger.error('Kiosk process error:', err.message);
325
- this.process = null;
326
- this.handleCrash(1);
327
- });
328
-
329
- this.startPoll();
330
-
331
- return this.getStatus();
332
- }
333
-
334
- private killAllBrowser(): void {
335
- try {
336
- if (process.platform === 'win32') {
337
- const browserExe = this.getWindowsBrowserExecutableName();
338
- try {
339
- execSync(`taskkill /IM ${browserExe} /F`, { stdio: 'ignore', timeout: 5_000 });
340
- this.logger.info(`Killed kiosk browser instances (${browserExe})`);
341
- } catch {
342
- // No browser running, that's fine
343
- }
344
- } else {
345
- try {
346
- execSync('pkill -f chromium-browser || pkill -f chrome', { stdio: 'ignore', timeout: 5_000 });
347
- } catch {
348
- // No browser running
349
- }
350
- }
351
- } catch {
352
- // Best effort
353
- }
354
- }
355
-
356
- private handleCrash(code: number | null): void {
357
- // If process was intentionally killed (process set to null), skip
358
- if (this.process === null) {
359
- return;
360
- }
361
-
362
- this.process = null;
363
- this.stopPoll();
364
-
365
- // If restart() is in progress, skip auto-restart — restart() handles it
366
- if (this.restarting) {
367
- this.restarting = false;
368
- this.logger.info(`Kiosk exited with code ${code} during restart, deferring to restart()`);
369
- return;
370
- }
371
-
372
- const now = Date.now();
373
- const windowStart = now - this.config.crashWindowMs;
374
- this.crashTimestamps = [...this.crashTimestamps, now].filter((t) => t >= windowStart);
375
-
376
- this.logger.warn(
377
- `Kiosk exited with code ${code}. Crashes in window: ${this.crashTimestamps.length}/${this.config.maxCrashesInWindow}`
378
- );
379
-
380
- if (this.crashTimestamps.length >= this.config.maxCrashesInWindow) {
381
- this.crashLoopDetected = true;
382
- this.logger.error(
383
- `Crash loop detected (${this.crashTimestamps.length} crashes in ${this.config.crashWindowMs}ms). NOT restarting.`
384
- );
385
- return;
386
- }
387
-
388
- // Auto-restart after delay
389
- this.logger.info('Auto-restarting kiosk in 2s...');
390
- setTimeout(() => {
391
- this.launch(this.currentUrl || undefined).catch((err) => {
392
- this.logger.error('Failed to auto-restart kiosk:', err);
393
- });
394
- }, 2_000);
395
- }
396
-
397
- private startPoll(): void {
398
- this.stopPoll();
399
- this.pollTimer = setInterval(() => {
400
- if (this.process && this.process.exitCode !== null) {
401
- // Process died but exit event didn't fire
402
- this.logger.warn('Poll detected kiosk process died');
403
- this.handleCrash(null);
404
- }
405
- }, this.config.pollIntervalMs);
406
- }
407
-
408
- private stopPoll(): void {
409
- if (this.pollTimer) {
410
- clearInterval(this.pollTimer);
411
- this.pollTimer = null;
412
- }
413
- }
414
- }
1
+ import { spawn, execSync } from 'child_process';
2
+ import { writeFileSync, readFileSync, existsSync } from 'fs';
3
+ import { resolve } from 'path';
4
+ import type { ChildProcess } from 'child_process';
5
+ import type { KioskConfig, KioskStatus } from '../lib/types.js';
6
+ import type { Logger } from '../lib/logger.js';
7
+
8
+ /**
9
+ * URL sidecar file — used in shell mode so the shell BAT reads the current
10
+ * target URL before launching Chrome. Agent writes, shell reads.
11
+ */
12
+ const URL_SIDECAR_FILE = process.platform === 'win32'
13
+ ? 'C:\\ProgramData\\Lightman\\kiosk-url.txt'
14
+ : '/tmp/lightman-kiosk-url.txt';
15
+
16
+ export class KioskManager {
17
+ private config: KioskConfig;
18
+ private logger: Logger;
19
+ private shellMode: boolean;
20
+ private process: ChildProcess | null = null;
21
+ private currentUrl: string | null = null;
22
+ private startedAt: number | null = null;
23
+ private crashTimestamps: number[] = [];
24
+ private crashLoopDetected = false;
25
+ private restarting = false;
26
+ private pollTimer: NodeJS.Timeout | null = null;
27
+
28
+ constructor(config: KioskConfig, logger: Logger) {
29
+ this.config = config;
30
+ this.logger = logger;
31
+ this.shellMode = config.shellMode ?? false;
32
+
33
+ if (this.shellMode) {
34
+ this.logger.info('KioskManager: shell mode enabled - Chrome lifecycle managed by Windows shell');
35
+ // Shell BAT reads slug directly from agent.config.json - no sidecar needed
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Launch the kiosk browser.
41
+ *
42
+ * Standard mode: spawns Chrome as a child process.
43
+ * Shell mode: writes URL to sidecar file. If Chrome isn't running, kills
44
+ * any stale instance (the shell's infinite loop will relaunch it).
45
+ * If Chrome IS running, kills it so the shell relaunches with new URL.
46
+ */
47
+ async launch(url?: string): Promise<KioskStatus> {
48
+ const targetUrl = url || this.config.defaultUrl;
49
+ this.currentUrl = targetUrl;
50
+
51
+ if (this.shellMode) {
52
+ return this.shellLaunch(targetUrl);
53
+ }
54
+ return this.standardLaunch(targetUrl);
55
+ }
56
+
57
+ async kill(): Promise<void> {
58
+ if (this.shellMode) {
59
+ // In shell mode, we just kill Chrome — the shell BAT will relaunch it
60
+ this.killAllChrome();
61
+ return;
62
+ }
63
+
64
+ this.stopPoll();
65
+ if (!this.process) {
66
+ return;
67
+ }
68
+
69
+ const proc = this.process;
70
+ this.process = null;
71
+
72
+ return new Promise<void>((resolve) => {
73
+ let killed = false;
74
+
75
+ const onExit = () => {
76
+ if (killed) return;
77
+ killed = true;
78
+ clearTimeout(forceKillTimer);
79
+ resolve();
80
+ };
81
+
82
+ const forceKillTimer = setTimeout(() => {
83
+ if (!killed) {
84
+ this.logger.warn('Kiosk did not exit after SIGTERM, sending SIGKILL');
85
+ try {
86
+ proc.kill('SIGKILL');
87
+ } catch {
88
+ // Process may already be dead
89
+ }
90
+ }
91
+ }, 5_000);
92
+
93
+ // Remove the crash handler so kill doesn't trigger auto-restart
94
+ proc.removeAllListeners('exit');
95
+ proc.once('exit', onExit);
96
+
97
+ try {
98
+ proc.kill('SIGTERM');
99
+ } catch {
100
+ // Process already dead
101
+ onExit();
102
+ }
103
+ });
104
+ }
105
+
106
+ async navigate(url: string): Promise<void> {
107
+ this.logger.info(`Navigating kiosk to: ${url}`);
108
+
109
+ if (this.shellMode) {
110
+ // Write new URL → kill Chrome → shell relaunches with new URL
111
+ this.writeUrlSidecar(url);
112
+ this.currentUrl = url;
113
+ this.killAllChrome();
114
+ return;
115
+ }
116
+
117
+ await this.kill();
118
+ await this.launch(url);
119
+ }
120
+
121
+ async restart(): Promise<KioskStatus> {
122
+ this.logger.info('Restarting kiosk');
123
+
124
+ if (this.shellMode) {
125
+ // Just kill Chrome — shell relaunches it with same URL from sidecar
126
+ this.killAllChrome();
127
+ // Give shell time to relaunch
128
+ await new Promise((r) => setTimeout(r, 5_000));
129
+ return this.getStatus();
130
+ }
131
+
132
+ this.restarting = true;
133
+ const url = this.currentUrl;
134
+ await this.kill();
135
+ return this.launch(url || undefined);
136
+ }
137
+
138
+ getStatus(): KioskStatus {
139
+ if (this.shellMode) {
140
+ return this.getShellModeStatus();
141
+ }
142
+
143
+ const running = this.process !== null && this.process.exitCode === null;
144
+ return {
145
+ running,
146
+ pid: running && this.process ? this.process.pid ?? null : null,
147
+ url: this.currentUrl,
148
+ crashCount: this.crashTimestamps.length,
149
+ crashLoopDetected: this.crashLoopDetected,
150
+ uptimeMs: running && this.startedAt ? Date.now() - this.startedAt : null,
151
+ };
152
+ }
153
+
154
+ destroy(): void {
155
+ this.stopPoll();
156
+ if (this.process) {
157
+ try {
158
+ this.process.removeAllListeners();
159
+ this.process.kill('SIGKILL');
160
+ } catch {
161
+ // Process may already be dead
162
+ }
163
+ this.process = null;
164
+ }
165
+ // In shell mode, do NOT kill Chrome on agent shutdown — the shell keeps it alive
166
+ }
167
+
168
+ // =====================================================================
169
+ // Shell Mode Methods
170
+ // =====================================================================
171
+
172
+ private async shellLaunch(targetUrl: string): Promise<KioskStatus> {
173
+ this.currentUrl = targetUrl;
174
+
175
+ // Shell mode: Chrome is managed by lightman-shell.bat.
176
+ // Shell reads slug from agent.config.json directly.
177
+ // Agent NEVER kills Chrome on startup - only on explicit navigate().
178
+ if (this.isChromeRunning()) {
179
+ this.logger.info('Shell mode: Chrome already running. Not touching it.');
180
+ } else {
181
+ this.logger.info('Shell mode: Chrome not running. Shell BAT will launch it.');
182
+ }
183
+
184
+ this.startedAt = this.startedAt || Date.now();
185
+ return this.getStatus();
186
+ }
187
+
188
+ private getShellModeStatus(): KioskStatus {
189
+ const running = this.isChromeRunning();
190
+ return {
191
+ running,
192
+ pid: running ? this.getChromePid() : null,
193
+ url: this.currentUrl || this.readUrlSidecar(),
194
+ crashCount: 0, // Shell handles crash recovery, not us
195
+ crashLoopDetected: false,
196
+ uptimeMs: running && this.startedAt ? Date.now() - this.startedAt : null,
197
+ };
198
+ }
199
+
200
+ /** Write the target URL to the sidecar file that the shell BAT reads */
201
+ private writeUrlSidecar(url: string): void {
202
+ try {
203
+ writeFileSync(URL_SIDECAR_FILE, url, 'utf-8');
204
+ } catch (err) {
205
+ this.logger.error('Failed to write URL sidecar:', err instanceof Error ? err.message : String(err));
206
+ }
207
+ }
208
+
209
+ /** Read the current URL from sidecar file */
210
+ private readUrlSidecar(): string | null {
211
+ try {
212
+ if (existsSync(URL_SIDECAR_FILE)) {
213
+ return readFileSync(URL_SIDECAR_FILE, 'utf-8').trim();
214
+ }
215
+ } catch {
216
+ // Best effort
217
+ }
218
+ return null;
219
+ }
220
+
221
+ /** Check if any chrome.exe process is running */
222
+ private isChromeRunning(): boolean {
223
+ try {
224
+ if (process.platform === 'win32') {
225
+ const result = execSync('tasklist /FI "IMAGENAME eq chrome.exe" /NH', {
226
+ encoding: 'utf-8',
227
+ timeout: 5_000,
228
+ stdio: ['pipe', 'pipe', 'ignore'],
229
+ });
230
+ return result.toLowerCase().includes('chrome.exe');
231
+ } else {
232
+ execSync('pgrep -x chrome || pgrep -x chromium-browser', {
233
+ stdio: 'ignore',
234
+ timeout: 5_000,
235
+ });
236
+ return true;
237
+ }
238
+ } catch {
239
+ return false;
240
+ }
241
+ }
242
+
243
+ /** Get PID of main Chrome process */
244
+ private getChromePid(): number | null {
245
+ try {
246
+ if (process.platform === 'win32') {
247
+ const result = execSync(
248
+ 'wmic process where "name=\'chrome.exe\' and CommandLine like \'%--kiosk%\'" get ProcessId /format:value',
249
+ { encoding: 'utf-8', timeout: 5_000, stdio: ['pipe', 'pipe', 'ignore'] }
250
+ );
251
+ const match = result.match(/ProcessId=(\d+)/);
252
+ return match ? parseInt(match[1], 10) : null;
253
+ }
254
+ } catch {
255
+ // Best effort
256
+ }
257
+ return null;
258
+ }
259
+
260
+ // =====================================================================
261
+ // Standard Mode Methods (original behavior)
262
+ // =====================================================================
263
+
264
+ private async standardLaunch(targetUrl: string): Promise<KioskStatus> {
265
+ // Kill existing process if running
266
+ if (this.process) {
267
+ await this.kill();
268
+ }
269
+
270
+ // Kill any leftover Chrome kiosk instances
271
+ this.killAllChrome();
272
+
273
+ // Delay to let Chrome fully release profile lock
274
+ await new Promise((r) => setTimeout(r, 2_000));
275
+
276
+ const args = [
277
+ '--kiosk',
278
+ '--noerrdialogs',
279
+ '--disable-infobars',
280
+ '--disable-session-crashed-bubble',
281
+ '--no-first-run',
282
+ '--no-default-browser-check',
283
+ ...this.config.extraArgs,
284
+ targetUrl,
285
+ ];
286
+
287
+ this.logger.info(`Launching kiosk: ${this.config.browserPath} → ${targetUrl}`);
288
+
289
+ this.process = spawn(this.config.browserPath, args, {
290
+ stdio: 'ignore',
291
+ detached: true,
292
+ });
293
+
294
+ // Unref so the agent process isn't held open by Chrome
295
+ this.process.unref();
296
+
297
+ this.currentUrl = targetUrl;
298
+ this.startedAt = Date.now();
299
+ this.crashLoopDetected = false;
300
+
301
+ this.process.on('exit', (code) => {
302
+ this.handleCrash(code);
303
+ });
304
+
305
+ this.process.on('error', (err) => {
306
+ this.logger.error('Kiosk process error:', err.message);
307
+ this.process = null;
308
+ this.handleCrash(1);
309
+ });
310
+
311
+ this.startPoll();
312
+
313
+ return this.getStatus();
314
+ }
315
+
316
+ private killAllChrome(): void {
317
+ try {
318
+ if (process.platform === 'win32') {
319
+ try {
320
+ execSync('taskkill /IM chrome.exe /F', { stdio: 'ignore', timeout: 5_000 });
321
+ this.logger.info('Killed Chrome instances');
322
+ } catch {
323
+ // No Chrome running, that's fine
324
+ }
325
+ } else {
326
+ try {
327
+ execSync('pkill -f chromium-browser || pkill -f chrome', { stdio: 'ignore', timeout: 5_000 });
328
+ } catch {
329
+ // No browser running
330
+ }
331
+ }
332
+ } catch {
333
+ // Best effort
334
+ }
335
+ }
336
+
337
+ private handleCrash(code: number | null): void {
338
+ // If process was intentionally killed (process set to null), skip
339
+ if (this.process === null) {
340
+ return;
341
+ }
342
+
343
+ this.process = null;
344
+ this.stopPoll();
345
+
346
+ // If restart() is in progress, skip auto-restart restart() handles it
347
+ if (this.restarting) {
348
+ this.restarting = false;
349
+ this.logger.info(`Kiosk exited with code ${code} during restart, deferring to restart()`);
350
+ return;
351
+ }
352
+
353
+ const now = Date.now();
354
+ const windowStart = now - this.config.crashWindowMs;
355
+ this.crashTimestamps = [...this.crashTimestamps, now].filter((t) => t >= windowStart);
356
+
357
+ this.logger.warn(
358
+ `Kiosk exited with code ${code}. Crashes in window: ${this.crashTimestamps.length}/${this.config.maxCrashesInWindow}`
359
+ );
360
+
361
+ if (this.crashTimestamps.length >= this.config.maxCrashesInWindow) {
362
+ this.crashLoopDetected = true;
363
+ this.logger.error(
364
+ `Crash loop detected (${this.crashTimestamps.length} crashes in ${this.config.crashWindowMs}ms). NOT restarting.`
365
+ );
366
+ return;
367
+ }
368
+
369
+ // Auto-restart after delay
370
+ this.logger.info('Auto-restarting kiosk in 2s...');
371
+ setTimeout(() => {
372
+ this.launch(this.currentUrl || undefined).catch((err) => {
373
+ this.logger.error('Failed to auto-restart kiosk:', err);
374
+ });
375
+ }, 2_000);
376
+ }
377
+
378
+ private startPoll(): void {
379
+ this.stopPoll();
380
+ this.pollTimer = setInterval(() => {
381
+ if (this.process && this.process.exitCode !== null) {
382
+ // Process died but exit event didn't fire
383
+ this.logger.warn('Poll detected kiosk process died');
384
+ this.handleCrash(null);
385
+ }
386
+ }, this.config.pollIntervalMs);
387
+ }
388
+
389
+ private stopPoll(): void {
390
+ if (this.pollTimer) {
391
+ clearInterval(this.pollTimer);
392
+ this.pollTimer = null;
393
+ }
394
+ }
395
+ }