@phnx-labs/agents-cli 1.22.54 → 1.22.55

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 (51) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +19 -2
  3. package/dist/commands/routines.js +31 -2
  4. package/dist/commands/share.d.ts +27 -0
  5. package/dist/commands/share.js +85 -0
  6. package/dist/commands/view.d.ts +8 -0
  7. package/dist/commands/view.js +31 -4
  8. package/dist/lib/accounting/usage.d.ts +31 -0
  9. package/dist/lib/accounting/usage.js +45 -4
  10. package/dist/lib/browser/cdp.d.ts +1 -1
  11. package/dist/lib/browser/cdp.js +1 -1
  12. package/dist/lib/browser/ffmpeg.d.ts +12 -0
  13. package/dist/lib/browser/ffmpeg.js +184 -0
  14. package/dist/lib/browser/service.js +119 -25
  15. package/dist/lib/daemon/browser-task-reap-service.d.ts +14 -0
  16. package/dist/lib/daemon/browser-task-reap-service.js +26 -0
  17. package/dist/lib/daemon/daemon.js +57 -172
  18. package/dist/lib/daemon/heartbeat-service.d.ts +13 -0
  19. package/dist/lib/daemon/heartbeat-service.js +26 -0
  20. package/dist/lib/daemon/monitor-engine-service.d.ts +9 -5
  21. package/dist/lib/daemon/monitor-engine-service.js +15 -7
  22. package/dist/lib/daemon/runner.d.ts +15 -0
  23. package/dist/lib/daemon/runner.js +23 -0
  24. package/dist/lib/daemon/secrets-broker-service.d.ts +5 -4
  25. package/dist/lib/daemon/secrets-broker-service.js +17 -32
  26. package/dist/lib/daemon/service.d.ts +2 -2
  27. package/dist/lib/daemon/service.js +1 -1
  28. package/dist/lib/daemon/session-state-service.d.ts +21 -0
  29. package/dist/lib/daemon/session-state-service.js +34 -0
  30. package/dist/lib/daemon/supervisor.d.ts +17 -7
  31. package/dist/lib/daemon/supervisor.js +87 -14
  32. package/dist/lib/daemon/tmux-reap-service.d.ts +11 -0
  33. package/dist/lib/daemon/tmux-reap-service.js +28 -0
  34. package/dist/lib/daemon/webhook-receiver-service.d.ts +9 -0
  35. package/dist/lib/daemon/webhook-receiver-service.js +17 -0
  36. package/dist/lib/daemon-services.d.ts +1 -1
  37. package/dist/lib/daemon-services.js +20 -0
  38. package/dist/lib/devices/harness-inventory.js +5 -2
  39. package/dist/lib/monitors/engine.d.ts +5 -1
  40. package/dist/lib/monitors/engine.js +5 -3
  41. package/dist/lib/scheduling/routines.d.ts +47 -0
  42. package/dist/lib/scheduling/routines.js +70 -1
  43. package/dist/lib/session/db.d.ts +2 -1
  44. package/dist/lib/session/db.js +2 -1
  45. package/dist/lib/share/capture.js +11 -2
  46. package/dist/lib/share/publish.d.ts +4 -1
  47. package/dist/lib/share/publish.js +27 -1
  48. package/dist/lib/share/worker-template.d.ts +3 -12
  49. package/dist/lib/share/worker-template.js +279 -3
  50. package/dist/lib/view-types.d.ts +6 -1
  51. package/package.json +8 -2
@@ -0,0 +1,184 @@
1
+ import { execFile } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ const execFileAsync = promisify(execFile);
7
+ let resolvedFfmpeg;
8
+ function canonicalDirs(platform) {
9
+ if (platform === 'darwin')
10
+ return ['/opt/homebrew/bin', '/usr/local/bin'];
11
+ if (platform === 'linux')
12
+ return ['/usr/local/bin', '/usr/bin', '/snap/bin'];
13
+ return [];
14
+ }
15
+ function pathCandidates(env, platform, platformDirs) {
16
+ const names = platform === 'win32' ? ['ffmpeg.exe'] : ['ffmpeg'];
17
+ const dirs = [...new Set([...(env.PATH ?? '').split(path.delimiter).filter(Boolean), ...platformDirs])];
18
+ return dirs.flatMap((dir) => names.map((name) => path.join(dir, name)));
19
+ }
20
+ function managedCandidates(home, platform) {
21
+ const name = platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg';
22
+ return [
23
+ path.join(home, '.agents', '.cache', 'bin', name),
24
+ path.join(home, '.agents', '.cache', 'ffmpeg', 'bin', name),
25
+ path.join(home, '.agents', '.cache', 'ffmpeg', 'node_modules', 'ffmpeg-static', name),
26
+ ];
27
+ }
28
+ async function isUsable(binary) {
29
+ try {
30
+ await execFileAsync(binary, ['-version'], { timeout: 10_000, windowsHide: true });
31
+ return true;
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
37
+ async function firstUsable(candidates) {
38
+ for (const candidate of candidates) {
39
+ if (fs.existsSync(candidate) && await isUsable(candidate))
40
+ return candidate;
41
+ }
42
+ return undefined;
43
+ }
44
+ function commandOnPath(name, env, platform, platformDirs) {
45
+ const names = platform === 'win32' ? [`${name}.exe`, `${name}.cmd`, name] : [name];
46
+ const dirs = [...new Set([...(env.PATH ?? '').split(path.delimiter).filter(Boolean), ...platformDirs])];
47
+ for (const dir of dirs) {
48
+ for (const candidate of names.map((entry) => path.join(dir, entry))) {
49
+ try {
50
+ fs.accessSync(candidate, platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
51
+ return candidate;
52
+ }
53
+ catch { /* keep looking */ }
54
+ }
55
+ }
56
+ return undefined;
57
+ }
58
+ export function ffmpegManualCommand(platform, osRelease = '') {
59
+ if (platform === 'darwin')
60
+ return 'brew install ffmpeg';
61
+ if (platform === 'win32')
62
+ return 'winget install Gyan.FFmpeg';
63
+ if (/\b(ID|ID_LIKE)=(?:"[^"]*\b)?(?:ubuntu|debian)\b/m.test(osRelease)) {
64
+ return 'sudo apt-get update && sudo apt-get install -y ffmpeg';
65
+ }
66
+ return 'install ffmpeg with your system package manager';
67
+ }
68
+ function readOsRelease() {
69
+ try {
70
+ return fs.readFileSync('/etc/os-release', 'utf8');
71
+ }
72
+ catch {
73
+ return '';
74
+ }
75
+ }
76
+ async function installManagedFfmpeg(home, env, platform, platformDirs) {
77
+ const npm = commandOnPath('npm', env, platform, platformDirs);
78
+ if (!npm)
79
+ return undefined;
80
+ const prefix = path.join(home, '.agents', '.cache', 'ffmpeg');
81
+ await fs.promises.mkdir(prefix, { recursive: true });
82
+ await execFileAsync(npm, [
83
+ 'install', '--prefix', prefix, '--no-save', '--no-audit', '--no-fund',
84
+ 'ffmpeg-static@5.2.0',
85
+ ], { env, timeout: 10 * 60_000, windowsHide: true });
86
+ return firstUsable(managedCandidates(home, platform));
87
+ }
88
+ function installAttempt(platform, env, osRelease, platformDirs) {
89
+ if (platform === 'darwin') {
90
+ const brew = commandOnPath('brew', env, platform, platformDirs);
91
+ return brew ? [{ command: brew, args: ['install', 'ffmpeg'] }] : undefined;
92
+ }
93
+ if (platform === 'win32') {
94
+ const winget = commandOnPath('winget', env, platform, platformDirs);
95
+ return winget ? [{ command: winget, args: ['install', '--id', 'Gyan.FFmpeg', '--exact', '--accept-source-agreements', '--accept-package-agreements'] }] : undefined;
96
+ }
97
+ if (platform !== 'linux')
98
+ return undefined;
99
+ if (/\b(ID|ID_LIKE)=(?:"[^"]*\b)?(?:ubuntu|debian)\b/m.test(osRelease)) {
100
+ const apt = commandOnPath('apt-get', env, platform, platformDirs);
101
+ if (!apt)
102
+ return undefined;
103
+ if (typeof process.getuid === 'function' && process.getuid() === 0) {
104
+ return [
105
+ { command: apt, args: ['update'] },
106
+ { command: apt, args: ['install', '-y', 'ffmpeg'] },
107
+ ];
108
+ }
109
+ const sudo = commandOnPath('sudo', env, platform, platformDirs);
110
+ return sudo ? [
111
+ { command: sudo, args: ['-n', apt, 'update'] },
112
+ { command: sudo, args: ['-n', apt, 'install', '-y', 'ffmpeg'] },
113
+ ] : undefined;
114
+ }
115
+ const managers = [
116
+ ['apk', ['add', 'ffmpeg']],
117
+ ['dnf', ['install', '-y', 'ffmpeg']],
118
+ ['yum', ['install', '-y', 'ffmpeg']],
119
+ ['pacman', ['-S', '--noconfirm', 'ffmpeg']],
120
+ ['zypper', ['--non-interactive', 'install', 'ffmpeg']],
121
+ ];
122
+ for (const [name, args] of managers) {
123
+ const command = commandOnPath(name, env, platform, platformDirs);
124
+ if (command)
125
+ return [{ command, args }];
126
+ }
127
+ return undefined;
128
+ }
129
+ export async function resolveFfmpeg(options = {}) {
130
+ if (resolvedFfmpeg)
131
+ return resolvedFfmpeg;
132
+ resolvedFfmpeg = (async () => {
133
+ const env = options.env ?? process.env;
134
+ const home = options.home ?? os.homedir();
135
+ const platform = options.platform ?? os.platform();
136
+ const osRelease = options.osRelease ?? readOsRelease();
137
+ const executableDirs = options.executableDirs ?? canonicalDirs(platform);
138
+ const candidates = [...pathCandidates(env, platform, executableDirs), ...managedCandidates(home, platform)];
139
+ const existing = await firstUsable(candidates);
140
+ if (existing)
141
+ return existing;
142
+ const attempt = installAttempt(platform, env, osRelease, executableDirs);
143
+ let failure = 'no supported automatic installer was found';
144
+ if (attempt) {
145
+ try {
146
+ for (const step of attempt) {
147
+ await execFileAsync(step.command, step.args, {
148
+ env,
149
+ timeout: 10 * 60_000,
150
+ windowsHide: true,
151
+ });
152
+ }
153
+ const installed = await firstUsable([...pathCandidates(env, platform, executableDirs), ...managedCandidates(home, platform)]);
154
+ if (installed)
155
+ return installed;
156
+ failure = `${path.basename(attempt.at(-1).command)} completed but ffmpeg is still unavailable`;
157
+ }
158
+ catch (error) {
159
+ failure = `${path.basename(attempt.at(-1).command)} failed: ${error.message}`;
160
+ }
161
+ }
162
+ try {
163
+ const managed = await installManagedFfmpeg(home, env, platform, executableDirs);
164
+ if (managed)
165
+ return managed;
166
+ failure += '; managed npm installation was unavailable';
167
+ }
168
+ catch (error) {
169
+ failure += `; managed npm installation failed: ${error.message}`;
170
+ }
171
+ const manual = ffmpegManualCommand(platform, osRelease);
172
+ throw new Error(`ffmpeg is required for browser recording and automatic installation failed (${failure}). Run \`${manual}\`, then retry.`);
173
+ })();
174
+ try {
175
+ return await resolvedFfmpeg;
176
+ }
177
+ catch (error) {
178
+ resolvedFfmpeg = undefined;
179
+ throw error;
180
+ }
181
+ }
182
+ export function resetFfmpegResolverForTest() {
183
+ resolvedFfmpeg = undefined;
184
+ }
@@ -21,6 +21,7 @@ import { emit } from '../feed/events.js';
21
21
  import { resolveActor } from '../actor.js';
22
22
  import { recordBrowserSession } from '../session/db.js';
23
23
  import { sshExecAsync } from '../ssh-exec.js';
24
+ import { resolveFfmpeg } from './ffmpeg.js';
24
25
  /**
25
26
  * Canonical form for comparing a requested URL against what CDP reports for a
26
27
  * live target. `new URL('https://example.com').href` is `https://example.com/`,
@@ -1130,38 +1131,29 @@ export class BrowserService {
1130
1131
  await fs.promises.mkdir(recordingsDir, { recursive: true });
1131
1132
  const outputPath = path.join(recordingsDir, `${Date.now()}.webm`);
1132
1133
  // Resolve ffmpeg lazily so non-recording paths don't pay the import cost.
1133
- const { spawn } = await import('child_process');
1134
- // CDP `Page.startScreencast` delivers frames at a VARIABLE cadence (paints
1135
- // are event-driven, not clocked). Feeding those into a fixed `-framerate`
1136
- // input made ffmpeg assume every frame was 1/fps apart, so a page that
1137
- // painted slower/faster than `fps` played back at the wrong speed. Instead
1138
- // stamp each frame with its wall-clock arrival time
1139
- // (`-use_wallclock_as_timestamps 1`) and keep those timings on output
1140
- // (`-vsync vfr`), so playback duration matches the real capture.
1141
- const ffmpeg = spawn('ffmpeg', [
1134
+ const [{ spawn }, ffmpegPath] = await Promise.all([
1135
+ import('child_process'),
1136
+ resolveFfmpeg(),
1137
+ ]);
1138
+ // The frame pump below clocks the latest CDP paint into stdin at exactly
1139
+ // `fps`, including repeated frames while the page is static. Declare that
1140
+ // cadence to image2pipe so frame count / fps is the real capture duration.
1141
+ const ffmpeg = spawn(ffmpegPath, [
1142
1142
  '-loglevel', 'error',
1143
1143
  '-f', 'image2pipe',
1144
- '-use_wallclock_as_timestamps', '1',
1144
+ '-framerate', String(fps),
1145
1145
  '-i', '-',
1146
1146
  '-c:v', 'libvpx-vp9',
1147
1147
  '-b:v', '1M',
1148
1148
  '-pix_fmt', 'yuv420p',
1149
- '-vsync', 'vfr',
1150
1149
  '-y',
1151
1150
  outputPath,
1152
1151
  ], { stdio: ['pipe', 'ignore', 'pipe'], windowsHide: true });
1153
- // Wait for the spawn to confirm (or fail) before we wire CDP frames into a
1154
- // dead pipe. ENOENT from a missing ffmpeg surfaces here as a real error
1155
- // instead of a silently empty .webm.
1152
+ // Wait for the spawn to confirm before wiring CDP frames into a dead pipe.
1156
1153
  await new Promise((resolve, reject) => {
1157
1154
  const onError = (err) => {
1158
1155
  ffmpeg.off('spawn', onSpawn);
1159
- if (err.code === 'ENOENT') {
1160
- reject(new Error('ffmpeg not found on PATH — install via `brew install ffmpeg`'));
1161
- }
1162
- else {
1163
- reject(err);
1164
- }
1156
+ reject(err);
1165
1157
  };
1166
1158
  const onSpawn = () => {
1167
1159
  ffmpeg.off('error', onError);
@@ -1181,13 +1173,33 @@ export class BrowserService {
1181
1173
  });
1182
1174
  const ffmpegStderr = () => stderrBuf;
1183
1175
  ffmpeg.on('error', () => { });
1184
- // 30 fps is CDP's screencast cap; everyNthFrame = round(30/fps).
1185
- const everyNthFrame = Math.max(1, Math.round(30 / fps));
1186
- await conn.cdp.send('Page.startScreencast', { format: 'jpeg', quality: 60, everyNthFrame }, sessionId);
1187
- const frameHandler = (params) => {
1176
+ let frameCount = 0;
1177
+ let latestFrame;
1178
+ let stdinBlocked = false;
1179
+ let stdinError;
1180
+ ffmpeg.stdin?.on('drain', () => { stdinBlocked = false; });
1181
+ ffmpeg.stdin?.on('error', (error) => { stdinError = error; });
1182
+ const writeFrame = (frame) => {
1183
+ const stdin = ffmpeg.stdin;
1184
+ if (!stdin || stdin.destroyed || stdinError || stdinBlocked)
1185
+ return false;
1186
+ stdinBlocked = !stdin.write(frame);
1187
+ frameCount += 1;
1188
+ return true;
1189
+ };
1190
+ const frameIntervalMs = 1000 / fps;
1191
+ let resolveFirstFrame;
1192
+ const firstFrame = new Promise((resolve) => { resolveFirstFrame = resolve; });
1193
+ const frameHandler = (params, eventSessionId) => {
1194
+ if (eventSessionId !== sessionId)
1195
+ return;
1188
1196
  const p = params;
1189
1197
  try {
1190
- ffmpeg.stdin?.write(Buffer.from(p.data, 'base64'));
1198
+ latestFrame = Buffer.from(p.data, 'base64');
1199
+ if (frameCount === 0) {
1200
+ if (writeFrame(latestFrame))
1201
+ resolveFirstFrame();
1202
+ }
1191
1203
  }
1192
1204
  catch {
1193
1205
  // ffmpeg exited; ignore writes
@@ -1195,7 +1207,77 @@ export class BrowserService {
1195
1207
  // Must ack every frame or CDP stops sending.
1196
1208
  conn.cdp.send('Page.screencastFrameAck', { sessionId: p.sessionId }, sessionId).catch(() => { });
1197
1209
  };
1210
+ // CDP emits on paints, so a static page can legitimately produce only one
1211
+ // event in 15 seconds. Retain the latest real frame and clock it into the
1212
+ // encoder at --fps; new paints replace it, while quiet time still advances
1213
+ // the WebM timeline instead of collapsing to a one-frame, 0.04s artifact.
1214
+ const framePump = setInterval(() => {
1215
+ if (!latestFrame)
1216
+ return;
1217
+ try {
1218
+ writeFrame(latestFrame);
1219
+ }
1220
+ catch {
1221
+ // ffmpeg exited; recordStop reports its exit code and stderr.
1222
+ }
1223
+ }, frameIntervalMs);
1224
+ // Register BEFORE startScreencast. Chrome may emit its first frame before
1225
+ // the command response; missing that frame also misses its ack and stalls
1226
+ // the entire stream with ffmpeg's stdin still empty (PHNX-2600).
1198
1227
  conn.cdp.on('Page.screencastFrame', frameHandler);
1228
+ try {
1229
+ await conn.cdp.send('Page.enable', {}, sessionId);
1230
+ await conn.cdp.send('Page.startScreencast', {
1231
+ format: 'jpeg',
1232
+ quality: 60,
1233
+ // Chrome's frames are paint-driven, not a 30fps clock. Asking for
1234
+ // every third paint can suppress the only stored frame on a static
1235
+ // page, so receive every paint and enforce --fps when writing stdin.
1236
+ everyNthFrame: 1,
1237
+ maxFramesInFlight: 1,
1238
+ // A static/low-paint page may produce no new compositor frame after
1239
+ // startScreencast. Ask Chrome to send its stored last frame so the
1240
+ // first ack can bootstrap the stream instead of leaving ffmpeg stdin
1241
+ // empty forever (confirmed live against Chrome 151 / PHNX-2600).
1242
+ sendLastFrame: true,
1243
+ }, sessionId);
1244
+ await new Promise((resolve, reject) => {
1245
+ const timer = setTimeout(() => reject(new Error('Chrome produced no screencast frame within 5s; recording was not started.')), 5000);
1246
+ firstFrame.then(() => {
1247
+ clearTimeout(timer);
1248
+ resolve();
1249
+ });
1250
+ });
1251
+ }
1252
+ catch (error) {
1253
+ conn.cdp.off('Page.screencastFrame', frameHandler);
1254
+ clearInterval(framePump);
1255
+ try {
1256
+ await conn.cdp.send('Page.stopScreencast', {}, sessionId);
1257
+ }
1258
+ catch { /* session may be gone */ }
1259
+ try {
1260
+ ffmpeg.stdin?.end();
1261
+ }
1262
+ catch { /* process may already be gone */ }
1263
+ await new Promise((resolve) => {
1264
+ if (ffmpeg.exitCode !== null)
1265
+ return resolve();
1266
+ const timer = setTimeout(() => {
1267
+ try {
1268
+ ffmpeg.kill('SIGKILL');
1269
+ }
1270
+ catch { /* already gone */ }
1271
+ resolve();
1272
+ }, 1000);
1273
+ ffmpeg.once('exit', () => {
1274
+ clearTimeout(timer);
1275
+ resolve();
1276
+ });
1277
+ });
1278
+ await fs.promises.rm(outputPath, { force: true });
1279
+ throw error;
1280
+ }
1199
1281
  const durationMs = durationSec * 1000;
1200
1282
  const maxBytes = maxMb * 1024 * 1024;
1201
1283
  const state = {
@@ -1206,9 +1288,12 @@ export class BrowserService {
1206
1288
  maxBytes,
1207
1289
  ffmpeg,
1208
1290
  ffmpegStderr,
1291
+ encoderError: () => stdinError,
1292
+ get frameCount() { return frameCount; },
1209
1293
  sessionId,
1210
1294
  conn,
1211
1295
  frameHandler,
1296
+ framePump,
1212
1297
  durationTimer: setTimeout(() => {
1213
1298
  this.recordStop(taskId, 'duration-cap').catch(() => { });
1214
1299
  }, durationMs),
@@ -1242,6 +1327,7 @@ export class BrowserService {
1242
1327
  rec.stopReason = reason;
1243
1328
  clearTimeout(rec.durationTimer);
1244
1329
  clearInterval(rec.sizeCheckInterval);
1330
+ clearInterval(rec.framePump);
1245
1331
  rec.conn.cdp.off('Page.screencastFrame', rec.frameHandler);
1246
1332
  try {
1247
1333
  await rec.conn.cdp.send('Page.stopScreencast', {}, rec.sessionId);
@@ -1291,6 +1377,13 @@ export class BrowserService {
1291
1377
  `${rec.outputPath}; the file is likely corrupt or empty.` +
1292
1378
  (err ? ` ffmpeg: ${err.slice(-800)}` : ''));
1293
1379
  }
1380
+ const encoderError = rec.encoderError?.();
1381
+ if (encoderError) {
1382
+ throw new Error(`ffmpeg stopped accepting recording frames at ${rec.outputPath}: ${encoderError.message}`);
1383
+ }
1384
+ if (rec.frameCount === 0) {
1385
+ throw new Error(`Chrome delivered no frames for the recording at ${rec.outputPath}; the output is empty.`);
1386
+ }
1294
1387
  let bytes = 0;
1295
1388
  try {
1296
1389
  const st = await fs.promises.stat(rec.outputPath);
@@ -1995,6 +2088,7 @@ export class BrowserService {
1995
2088
  rec.conn.cdp.off('Page.screencastFrame', rec.frameHandler);
1996
2089
  }
1997
2090
  catch { /* socket may be gone */ }
2091
+ clearInterval(rec.framePump);
1998
2092
  try {
1999
2093
  rec.ffmpeg.stdin?.end();
2000
2094
  }
@@ -0,0 +1,14 @@
1
+ /** Abandoned browser-task cleanup under supervision. */
2
+ import { BrowserService } from '../browser/service.js';
3
+ import type { DaemonServiceId } from '../daemon-services.js';
4
+ import { BasePeriodicService, type DaemonContext } from './service.js';
5
+ export declare class BrowserTaskReapService extends BasePeriodicService {
6
+ private readonly browserService;
7
+ readonly id: DaemonServiceId;
8
+ readonly intervalMs: number;
9
+ readonly deadlineMs: number;
10
+ constructor(browserService: BrowserService);
11
+ protected onStart(_ctx: DaemonContext): Promise<void>;
12
+ protected onStop(): Promise<void>;
13
+ protected onTick(ctx: DaemonContext): Promise<void>;
14
+ }
@@ -0,0 +1,26 @@
1
+ /** Abandoned browser-task cleanup under supervision. */
2
+ import { resolveBrowserTaskIdleMs } from '../device-config.js';
3
+ import { BasePeriodicService } from './service.js';
4
+ const BROWSER_TASK_REAP_TICK_MS = 5 * 60_000;
5
+ const BROWSER_TASK_REAP_DEADLINE_MS = 2 * 60_000;
6
+ export class BrowserTaskReapService extends BasePeriodicService {
7
+ browserService;
8
+ id = 'browser-task-reap';
9
+ intervalMs = BROWSER_TASK_REAP_TICK_MS;
10
+ deadlineMs = BROWSER_TASK_REAP_DEADLINE_MS;
11
+ constructor(browserService) {
12
+ super();
13
+ this.browserService = browserService;
14
+ }
15
+ async onStart(_ctx) { }
16
+ async onStop() { }
17
+ async onTick(ctx) {
18
+ const result = await this.browserService.reapAbandoned({ idleMs: resolveBrowserTaskIdleMs() });
19
+ if (result.closed.length > 0) {
20
+ ctx.log('INFO', `Browser-task reaper: closed ${result.closed.length} task(s)`);
21
+ for (const closed of result.closed) {
22
+ ctx.log('INFO', ` ${closed.task} (${closed.reason}, profile ${closed.profile})`);
23
+ }
24
+ }
25
+ }
26
+ }