@everydaydevopsio/pilot 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +11 -0
- package/CHANGELOG.md +45 -0
- package/README.md +105 -11
- package/dist/browser/browser-manager.d.ts +7 -1
- package/dist/browser/browser-manager.d.ts.map +1 -1
- package/dist/browser/browser-manager.js +196 -40
- package/dist/browser/browser-manager.js.map +1 -1
- package/dist/browser/chrome-launcher.d.ts +13 -2
- package/dist/browser/chrome-launcher.d.ts.map +1 -1
- package/dist/browser/chrome-launcher.js +33 -4
- package/dist/browser/chrome-launcher.js.map +1 -1
- package/dist/browser/driver/capabilities.d.ts +22 -0
- package/dist/browser/driver/capabilities.d.ts.map +1 -0
- package/dist/browser/driver/capabilities.js +70 -0
- package/dist/browser/driver/capabilities.js.map +1 -0
- package/dist/browser/driver/cdp-driver.d.ts +77 -0
- package/dist/browser/driver/cdp-driver.d.ts.map +1 -0
- package/dist/browser/driver/cdp-driver.js +190 -0
- package/dist/browser/driver/cdp-driver.js.map +1 -0
- package/dist/browser/driver/factory.d.ts +10 -0
- package/dist/browser/driver/factory.d.ts.map +1 -0
- package/dist/browser/driver/factory.js +23 -0
- package/dist/browser/driver/factory.js.map +1 -0
- package/dist/browser/driver/index.d.ts +6 -0
- package/dist/browser/driver/index.d.ts.map +1 -0
- package/dist/browser/driver/index.js +5 -0
- package/dist/browser/driver/index.js.map +1 -0
- package/dist/browser/driver/types.d.ts +106 -0
- package/dist/browser/driver/types.d.ts.map +1 -0
- package/dist/browser/driver/types.js +25 -0
- package/dist/browser/driver/types.js.map +1 -0
- package/dist/browser/sessions.d.ts +49 -0
- package/dist/browser/sessions.d.ts.map +1 -0
- package/dist/browser/sessions.js +249 -0
- package/dist/browser/sessions.js.map +1 -0
- package/dist/browser/types.d.ts +13 -0
- package/dist/browser/types.d.ts.map +1 -1
- package/dist/cli/skill-template.d.ts +1 -1
- package/dist/cli/skill-template.d.ts.map +1 -1
- package/dist/cli/skill-template.js +1 -1
- package/dist/cli/skill-template.js.map +1 -1
- package/dist/commands/connect.d.ts +2 -2
- package/dist/commands/connect.d.ts.map +1 -1
- package/dist/commands/connect.js +2 -2
- package/dist/commands/connect.js.map +1 -1
- package/dist/mcp/server.d.ts +9 -15
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +32 -74
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tools/browser.d.ts +2 -3
- package/dist/mcp/tools/browser.d.ts.map +1 -1
- package/dist/mcp/tools/browser.js +85 -70
- package/dist/mcp/tools/browser.js.map +1 -1
- package/dist/mcp/tools/connect.d.ts +2 -3
- package/dist/mcp/tools/connect.d.ts.map +1 -1
- package/dist/mcp/tools/connect.js +9 -9
- package/dist/mcp/tools/connect.js.map +1 -1
- package/dist/mcp/tools/driver-context.d.ts +14 -0
- package/dist/mcp/tools/driver-context.d.ts.map +1 -0
- package/dist/mcp/tools/driver-context.js +43 -0
- package/dist/mcp/tools/driver-context.js.map +1 -0
- package/dist/mcp/tools/files.d.ts.map +1 -1
- package/dist/mcp/tools/files.js +5 -15
- package/dist/mcp/tools/files.js.map +1 -1
- package/dist/mcp/tools/interaction.d.ts.map +1 -1
- package/dist/mcp/tools/interaction.js +7 -26
- package/dist/mcp/tools/interaction.js.map +1 -1
- package/dist/mcp/tools/names.d.ts +1 -1
- package/dist/mcp/tools/names.d.ts.map +1 -1
- package/dist/mcp/tools/names.js +2 -0
- package/dist/mcp/tools/names.js.map +1 -1
- package/dist/mcp/tools/network.d.ts.map +1 -1
- package/dist/mcp/tools/network.js +3 -11
- package/dist/mcp/tools/network.js.map +1 -1
- package/dist/mcp/tools/performance.d.ts.map +1 -1
- package/dist/mcp/tools/performance.js +2 -12
- package/dist/mcp/tools/performance.js.map +1 -1
- package/dist/mcp/tools/snapshot.d.ts.map +1 -1
- package/dist/mcp/tools/snapshot.js +3 -14
- package/dist/mcp/tools/snapshot.js.map +1 -1
- package/dist/mcp/tools/styles.d.ts.map +1 -1
- package/dist/mcp/tools/styles.js +2 -11
- package/dist/mcp/tools/styles.js.map +1 -1
- package/package.json +1 -1
- package/skills/pilot/SKILL.md +9 -0
- package/src/browser/browser-manager.ts +248 -41
- package/src/browser/chrome-launcher.ts +40 -4
- package/src/browser/driver/capabilities.ts +88 -0
- package/src/browser/driver/cdp-driver.ts +260 -0
- package/src/browser/driver/factory.ts +33 -0
- package/src/browser/driver/index.ts +22 -0
- package/src/browser/driver/types.ts +156 -0
- package/src/browser/sessions.ts +299 -0
- package/src/browser/types.ts +14 -0
- package/src/cli/skill-template.ts +1 -1
- package/src/commands/connect.ts +3 -3
- package/src/mcp/server.ts +48 -91
- package/src/mcp/tools/browser.ts +105 -87
- package/src/mcp/tools/connect.ts +10 -11
- package/src/mcp/tools/driver-context.ts +56 -0
- package/src/mcp/tools/files.ts +5 -16
- package/src/mcp/tools/interaction.ts +7 -27
- package/src/mcp/tools/names.ts +2 -0
- package/src/mcp/tools/network.ts +2 -12
- package/src/mcp/tools/performance.ts +2 -13
- package/src/mcp/tools/snapshot.ts +3 -15
- package/src/mcp/tools/styles.ts +2 -12
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { Client } from 'chrome-remote-interface';
|
|
2
2
|
import type { ChildProcess } from 'child_process';
|
|
3
3
|
import { mkdirSync } from 'fs';
|
|
4
|
+
import { hostname } from 'os';
|
|
5
|
+
import { basename, join } from 'path';
|
|
4
6
|
import { getLogger } from '../util/logger.js';
|
|
5
7
|
import type { Config } from '../util/config.js';
|
|
6
8
|
import type { ViewportConfig } from '../viewport.js';
|
|
@@ -9,6 +11,7 @@ import type {
|
|
|
9
11
|
BrowserEventCallback,
|
|
10
12
|
BrowserState,
|
|
11
13
|
LaunchOptions,
|
|
14
|
+
SessionInfo,
|
|
12
15
|
TabInfo
|
|
13
16
|
} from './types.js';
|
|
14
17
|
import {
|
|
@@ -18,7 +21,9 @@ import {
|
|
|
18
21
|
launchChrome,
|
|
19
22
|
resolveUserDataDir,
|
|
20
23
|
waitForChromeReady,
|
|
21
|
-
cleanupLockFiles
|
|
24
|
+
cleanupLockFiles,
|
|
25
|
+
profileLockOwner,
|
|
26
|
+
DEFAULT_STARTUP_TIMEOUT_MS
|
|
22
27
|
} from './chrome-launcher.js';
|
|
23
28
|
import { assertLinuxBrowserPrerequisites } from './preflight.js';
|
|
24
29
|
import {
|
|
@@ -92,6 +97,30 @@ export class BrowserManager {
|
|
|
92
97
|
return this.external;
|
|
93
98
|
}
|
|
94
99
|
|
|
100
|
+
getSessionInfo(): SessionInfo {
|
|
101
|
+
if (this.external) {
|
|
102
|
+
return {
|
|
103
|
+
mode: 'external',
|
|
104
|
+
connected: this.client !== null,
|
|
105
|
+
host: this.externalHost ?? undefined,
|
|
106
|
+
cdpPort: this.externalPort ?? undefined
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (this.chromeProcess) {
|
|
110
|
+
return {
|
|
111
|
+
mode: 'launched',
|
|
112
|
+
connected: this.client !== null,
|
|
113
|
+
pid: this.chromeProcess.pid,
|
|
114
|
+
cdpPort: this.launchedCdpPort ?? undefined,
|
|
115
|
+
userDataDir: this.launchedUserDataDir ?? undefined,
|
|
116
|
+
profile: this.launchedUserDataDir
|
|
117
|
+
? basename(this.launchedUserDataDir)
|
|
118
|
+
: undefined
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return { mode: 'none', connected: false };
|
|
122
|
+
}
|
|
123
|
+
|
|
95
124
|
getViewportConfig(): ViewportConfig | null {
|
|
96
125
|
return this.viewportConfig;
|
|
97
126
|
}
|
|
@@ -159,9 +188,7 @@ export class BrowserManager {
|
|
|
159
188
|
const userDataDir = resolveUserDataDir(profileName);
|
|
160
189
|
|
|
161
190
|
if (isProfileLocked(userDataDir)) {
|
|
162
|
-
throw new Error(
|
|
163
|
-
`Profile "${profileName}" is already in use by another browser instance.`
|
|
164
|
-
);
|
|
191
|
+
throw new Error(describeProfileConflict(profileName, userDataDir));
|
|
165
192
|
}
|
|
166
193
|
|
|
167
194
|
const viewportPreset = opts.viewport ?? this.config.viewport;
|
|
@@ -198,18 +225,30 @@ export class BrowserManager {
|
|
|
198
225
|
this.launchedUserDataDir = userDataDir;
|
|
199
226
|
|
|
200
227
|
const stderrChunks: string[] = [];
|
|
201
|
-
this.chromeProcess
|
|
228
|
+
const proc = this.chromeProcess;
|
|
229
|
+
let spawnError: Error | null = null;
|
|
230
|
+
let exitStatus: string | null = null;
|
|
231
|
+
|
|
232
|
+
proc.stderr?.on('data', (data: Buffer) => {
|
|
202
233
|
stderrChunks.push(data.toString());
|
|
203
234
|
});
|
|
204
235
|
|
|
205
|
-
|
|
236
|
+
proc.on('error', (err) => {
|
|
237
|
+
spawnError = err;
|
|
238
|
+
getLogger().warn({ err, chromePath }, 'Chrome process failed to start');
|
|
239
|
+
// A failed spawn does not necessarily emit 'exit'.
|
|
240
|
+
if (this.chromeProcess === proc && proc.pid === undefined) {
|
|
241
|
+
this.chromeProcess = null;
|
|
242
|
+
this.launchedCdpPort = null;
|
|
243
|
+
this.launchedUserDataDir = null;
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
proc.on('exit', (code, signal) => {
|
|
206
248
|
const logger = getLogger();
|
|
207
249
|
const stderr = stderrChunks.join('').trim();
|
|
208
|
-
const stderrFiltered = stderr
|
|
209
|
-
|
|
210
|
-
.filter((l) => !l.includes('dbus/bus.cc'))
|
|
211
|
-
.join('\n')
|
|
212
|
-
.trim();
|
|
250
|
+
const stderrFiltered = filterChromeStderr(stderr);
|
|
251
|
+
exitStatus = signal ? `signal ${signal}` : `exit code ${code}`;
|
|
213
252
|
logger.warn(
|
|
214
253
|
{
|
|
215
254
|
code,
|
|
@@ -219,17 +258,78 @@ export class BrowserManager {
|
|
|
219
258
|
},
|
|
220
259
|
'Chrome process exited'
|
|
221
260
|
);
|
|
222
|
-
this.chromeProcess
|
|
223
|
-
|
|
224
|
-
|
|
261
|
+
if (this.chromeProcess === proc) {
|
|
262
|
+
this.chromeProcess = null;
|
|
263
|
+
this.launchedCdpPort = null;
|
|
264
|
+
this.cleanupUserDataDir(proc.pid);
|
|
265
|
+
}
|
|
225
266
|
});
|
|
226
267
|
|
|
227
|
-
|
|
268
|
+
const startupTimeoutMs =
|
|
269
|
+
opts.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS;
|
|
270
|
+
const earlyFailure = (): Error | null => {
|
|
271
|
+
if (spawnError) {
|
|
272
|
+
return new Error(
|
|
273
|
+
`Chrome failed to start (${chromePath}): ${spawnError.message}`,
|
|
274
|
+
{ cause: spawnError }
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
if (exitStatus) {
|
|
278
|
+
const stderr = filterChromeStderr(stderrChunks.join('').trim());
|
|
279
|
+
return new Error(
|
|
280
|
+
`Chrome exited (${exitStatus}) before DevTools became ready on port ${port}.` +
|
|
281
|
+
(stderr ? ` Chrome stderr: ${stderr.slice(-1000)}` : '')
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
return null;
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
await waitForChromeReady(port, startupTimeoutMs, earlyFailure);
|
|
289
|
+
} catch (err) {
|
|
290
|
+
const timedOut = earlyFailure() === null;
|
|
291
|
+
const cleanup = await this.abortLaunch(proc, userDataDir);
|
|
292
|
+
throw new Error(
|
|
293
|
+
describeLaunchFailure({
|
|
294
|
+
reason: (err as Error).message,
|
|
295
|
+
timedOut,
|
|
296
|
+
startupTimeoutMs,
|
|
297
|
+
chromePath,
|
|
298
|
+
profileName,
|
|
299
|
+
userDataDir,
|
|
300
|
+
headless,
|
|
301
|
+
port,
|
|
302
|
+
cleanup
|
|
303
|
+
}),
|
|
304
|
+
{ cause: err }
|
|
305
|
+
);
|
|
306
|
+
}
|
|
228
307
|
await this.connect();
|
|
229
308
|
|
|
230
309
|
return { headless, viewport: viewportPreset };
|
|
231
310
|
}
|
|
232
311
|
|
|
312
|
+
/**
|
|
313
|
+
* Stop a Chrome process whose launch failed so it does not keep holding
|
|
314
|
+
* the profile lock while this manager forgets about it.
|
|
315
|
+
*/
|
|
316
|
+
private async abortLaunch(
|
|
317
|
+
proc: ChildProcess,
|
|
318
|
+
userDataDir: string
|
|
319
|
+
): Promise<LaunchCleanup> {
|
|
320
|
+
if (this.chromeProcess === proc) {
|
|
321
|
+
this.chromeProcess = null;
|
|
322
|
+
this.launchedCdpPort = null;
|
|
323
|
+
}
|
|
324
|
+
this.launchedUserDataDir = null;
|
|
325
|
+
const pid = proc.pid;
|
|
326
|
+
const stopped = await stopChromeProcess(proc);
|
|
327
|
+
if (stopped && pid !== undefined) {
|
|
328
|
+
cleanupLockFiles(userDataDir, pid);
|
|
329
|
+
}
|
|
330
|
+
return { pid, stopped };
|
|
331
|
+
}
|
|
332
|
+
|
|
233
333
|
async connect(): Promise<void> {
|
|
234
334
|
if (this.destroyed) return;
|
|
235
335
|
const logger = getLogger();
|
|
@@ -404,9 +504,9 @@ export class BrowserManager {
|
|
|
404
504
|
this.retryTimer.unref();
|
|
405
505
|
}
|
|
406
506
|
|
|
407
|
-
private cleanupUserDataDir(): void {
|
|
408
|
-
if (this.launchedUserDataDir) {
|
|
409
|
-
cleanupLockFiles(this.launchedUserDataDir);
|
|
507
|
+
private cleanupUserDataDir(ownerPid: number | undefined): void {
|
|
508
|
+
if (this.launchedUserDataDir && ownerPid !== undefined) {
|
|
509
|
+
cleanupLockFiles(this.launchedUserDataDir, ownerPid);
|
|
410
510
|
}
|
|
411
511
|
this.launchedUserDataDir = null;
|
|
412
512
|
}
|
|
@@ -436,30 +536,137 @@ export class BrowserManager {
|
|
|
436
536
|
const proc = this.chromeProcess;
|
|
437
537
|
this.chromeProcess = null;
|
|
438
538
|
this.launchedCdpPort = null;
|
|
439
|
-
|
|
440
|
-
proc.
|
|
441
|
-
}
|
|
442
|
-
|
|
539
|
+
if (await stopChromeProcess(proc)) {
|
|
540
|
+
this.cleanupUserDataDir(proc.pid);
|
|
541
|
+
} else {
|
|
542
|
+
getLogger().warn(
|
|
543
|
+
{ pid: proc.pid },
|
|
544
|
+
'Chrome did not exit after SIGKILL; leaving its profile lock in place'
|
|
545
|
+
);
|
|
546
|
+
this.launchedUserDataDir = null;
|
|
443
547
|
}
|
|
444
|
-
await new Promise<void>((resolve) => {
|
|
445
|
-
if (proc.exitCode !== null) {
|
|
446
|
-
resolve();
|
|
447
|
-
return;
|
|
448
|
-
}
|
|
449
|
-
const timer = setTimeout(() => {
|
|
450
|
-
try {
|
|
451
|
-
proc.kill('SIGKILL');
|
|
452
|
-
} catch {
|
|
453
|
-
// process may have already exited
|
|
454
|
-
}
|
|
455
|
-
resolve();
|
|
456
|
-
}, 5000);
|
|
457
|
-
proc.once('exit', () => {
|
|
458
|
-
clearTimeout(timer);
|
|
459
|
-
resolve();
|
|
460
|
-
});
|
|
461
|
-
});
|
|
462
|
-
this.cleanupUserDataDir();
|
|
463
548
|
}
|
|
464
549
|
}
|
|
465
550
|
}
|
|
551
|
+
|
|
552
|
+
function filterChromeStderr(stderr: string): string {
|
|
553
|
+
return stderr
|
|
554
|
+
.split('\n')
|
|
555
|
+
.filter((l) => !l.includes('dbus/bus.cc'))
|
|
556
|
+
.join('\n')
|
|
557
|
+
.trim();
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
interface LaunchCleanup {
|
|
561
|
+
pid: number | undefined;
|
|
562
|
+
stopped: boolean;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Terminate a Chrome process, escalating to SIGKILL. Resolves true once it has exited. */
|
|
566
|
+
async function stopChromeProcess(proc: ChildProcess): Promise<boolean> {
|
|
567
|
+
const hasExited = () => proc.exitCode !== null || proc.signalCode !== null;
|
|
568
|
+
if (proc.pid === undefined || hasExited()) return true;
|
|
569
|
+
|
|
570
|
+
const waitForExit = (ms: number) =>
|
|
571
|
+
new Promise<boolean>((resolve) => {
|
|
572
|
+
if (hasExited()) {
|
|
573
|
+
resolve(true);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const timer = setTimeout(() => {
|
|
577
|
+
proc.off('exit', onExit);
|
|
578
|
+
resolve(hasExited());
|
|
579
|
+
}, ms);
|
|
580
|
+
const onExit = () => {
|
|
581
|
+
clearTimeout(timer);
|
|
582
|
+
resolve(true);
|
|
583
|
+
};
|
|
584
|
+
proc.once('exit', onExit);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
try {
|
|
588
|
+
proc.kill();
|
|
589
|
+
} catch {
|
|
590
|
+
// process may have already exited
|
|
591
|
+
}
|
|
592
|
+
if (await waitForExit(5000)) return true;
|
|
593
|
+
try {
|
|
594
|
+
proc.kill('SIGKILL');
|
|
595
|
+
} catch {
|
|
596
|
+
// process may have already exited
|
|
597
|
+
}
|
|
598
|
+
return waitForExit(2000);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function describeProfileConflict(
|
|
602
|
+
profileName: string,
|
|
603
|
+
userDataDir: string
|
|
604
|
+
): string {
|
|
605
|
+
const lockPath = join(userDataDir, 'SingletonLock');
|
|
606
|
+
const owner = profileLockOwner(userDataDir);
|
|
607
|
+
const message = [
|
|
608
|
+
`Profile "${profileName}" is already in use by another browser instance.`
|
|
609
|
+
];
|
|
610
|
+
if (owner && owner.lockHost === hostname()) {
|
|
611
|
+
message.push(
|
|
612
|
+
`Chrome pid ${owner.pid} on this host holds ${lockPath}. This Pilot session is not tracking that browser, so browser_stop will not stop it.`,
|
|
613
|
+
`Close that Chrome window or stop pid ${owner.pid}, or call browser_start with a different profileName.`
|
|
614
|
+
);
|
|
615
|
+
} else if (owner) {
|
|
616
|
+
message.push(
|
|
617
|
+
`The lock ${lockPath} was created by pid ${owner.pid} on host "${owner.lockHost}", not this host ("${hostname()}"), so Pilot cannot tell whether that browser is still running.`,
|
|
618
|
+
`Call browser_start with a different profileName. If no browser on "${owner.lockHost}" uses this profile any more, delete the lock file and retry.`
|
|
619
|
+
);
|
|
620
|
+
} else {
|
|
621
|
+
message.push(
|
|
622
|
+
`Pilot could not identify the owner of the lock in ${userDataDir}.`,
|
|
623
|
+
`Call browser_start with a different profileName. If no Chrome is using that directory, delete its SingletonLock and lockfile and retry.`
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
return message.join(' ');
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function describeLaunchFailure(details: {
|
|
630
|
+
reason: string;
|
|
631
|
+
timedOut: boolean;
|
|
632
|
+
startupTimeoutMs: number;
|
|
633
|
+
chromePath: string;
|
|
634
|
+
profileName: string;
|
|
635
|
+
userDataDir: string;
|
|
636
|
+
headless: boolean;
|
|
637
|
+
port: number;
|
|
638
|
+
cleanup: LaunchCleanup;
|
|
639
|
+
}): string {
|
|
640
|
+
const { cleanup } = details;
|
|
641
|
+
const display =
|
|
642
|
+
process.env.DISPLAY || process.env.WAYLAND_DISPLAY || '(not set)';
|
|
643
|
+
const launch = [
|
|
644
|
+
`chrome=${details.chromePath}`,
|
|
645
|
+
`profile="${details.profileName}" (${details.userDataDir})`,
|
|
646
|
+
`mode=${details.headless ? 'headless' : 'visible'}`,
|
|
647
|
+
`devtools port=${details.port}`,
|
|
648
|
+
...(process.platform === 'linux' && !details.headless
|
|
649
|
+
? [`display=${display}`]
|
|
650
|
+
: [])
|
|
651
|
+
].join(', ');
|
|
652
|
+
|
|
653
|
+
let cleanupText: string;
|
|
654
|
+
if (cleanup.pid === undefined) {
|
|
655
|
+
cleanupText = 'No Chrome process was left running.';
|
|
656
|
+
} else if (cleanup.stopped) {
|
|
657
|
+
cleanupText = `Pilot stopped the Chrome process it launched (pid ${cleanup.pid}); profile "${details.profileName}" is free to reuse.`;
|
|
658
|
+
} else {
|
|
659
|
+
cleanupText = `Chrome (pid ${cleanup.pid}) did not stop and may still hold profile "${details.profileName}". Stop it (kill -9 ${cleanup.pid}) or call browser_start with a different profileName.`;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const nextSteps = details.timedOut
|
|
663
|
+
? `Chrome can be slow on a first run or with software rendering; retry with a larger startupTimeoutMs (this attempt used ${details.startupTimeoutMs}ms). Run \`pilot check\` to diagnose the environment.`
|
|
664
|
+
: `Run \`pilot check\` to diagnose the environment${details.headless ? '' : ', or retry with headless: true if no graphical display is available'}.`;
|
|
665
|
+
|
|
666
|
+
return [
|
|
667
|
+
details.reason,
|
|
668
|
+
`Launch: ${launch}.`,
|
|
669
|
+
`Cleanup: ${cleanupText}`,
|
|
670
|
+
`Next: ${nextSteps}`
|
|
671
|
+
].join('\n');
|
|
672
|
+
}
|
|
@@ -188,12 +188,21 @@ export function formatPortBindError(error: NodeJS.ErrnoException): Error {
|
|
|
188
188
|
return error;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
// Cold starts on Linux servers (first-run profile setup, software rendering
|
|
192
|
+
// under Xvfb/VNC) can take well over 30s. A healthy start returns as soon as
|
|
193
|
+
// DevTools answers, and a Chrome that exits is detected immediately, so the
|
|
194
|
+
// longer ceiling only matters when Chrome is genuinely slow.
|
|
195
|
+
export const DEFAULT_STARTUP_TIMEOUT_MS = 90000;
|
|
196
|
+
|
|
191
197
|
export async function waitForChromeReady(
|
|
192
198
|
port: number,
|
|
193
|
-
timeoutMs =
|
|
199
|
+
timeoutMs = DEFAULT_STARTUP_TIMEOUT_MS,
|
|
200
|
+
earlyFailure?: () => Error | null
|
|
194
201
|
): Promise<void> {
|
|
195
202
|
const deadline = Date.now() + timeoutMs;
|
|
196
203
|
while (Date.now() < deadline) {
|
|
204
|
+
const failure = earlyFailure?.();
|
|
205
|
+
if (failure) throw failure;
|
|
197
206
|
try {
|
|
198
207
|
await CDP.List({ host: '127.0.0.1', port });
|
|
199
208
|
return;
|
|
@@ -201,6 +210,8 @@ export async function waitForChromeReady(
|
|
|
201
210
|
await new Promise((r) => setTimeout(r, 200));
|
|
202
211
|
}
|
|
203
212
|
}
|
|
213
|
+
const failure = earlyFailure?.();
|
|
214
|
+
if (failure) throw failure;
|
|
204
215
|
throw new Error(
|
|
205
216
|
`Chrome did not become ready on port ${port} within ${timeoutMs}ms`
|
|
206
217
|
);
|
|
@@ -271,12 +282,37 @@ export function launchChrome(opts: {
|
|
|
271
282
|
});
|
|
272
283
|
}
|
|
273
284
|
|
|
274
|
-
export function
|
|
285
|
+
export function profileLockOwner(
|
|
286
|
+
userDataDir: string
|
|
287
|
+
): { pid: number; lockHost: string } | null {
|
|
288
|
+
return extractLockPid(join(userDataDir, 'SingletonLock'));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Remove the profile lock files left by a Chrome process Pilot launched.
|
|
293
|
+
* A SingletonLock that names a different process is left alone: if Chrome
|
|
294
|
+
* handed off to another instance already using the profile, that lock
|
|
295
|
+
* belongs to the live browser. Returns false when a lock was left in place.
|
|
296
|
+
*/
|
|
297
|
+
export function cleanupLockFiles(
|
|
298
|
+
userDataDir: string,
|
|
299
|
+
ownerPid: number
|
|
300
|
+
): boolean {
|
|
301
|
+
const owner = profileLockOwner(userDataDir);
|
|
302
|
+
if (
|
|
303
|
+
fileOrSymlinkExists(join(userDataDir, 'SingletonLock')) &&
|
|
304
|
+
(!owner || owner.pid !== ownerPid || owner.lockHost !== hostname())
|
|
305
|
+
) {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
let removed = true;
|
|
275
309
|
for (const lockName of ['SingletonLock', 'lockfile']) {
|
|
276
310
|
try {
|
|
277
311
|
unlinkSync(join(userDataDir, lockName));
|
|
278
|
-
} catch {
|
|
279
|
-
// lock file
|
|
312
|
+
} catch (err) {
|
|
313
|
+
// A lock file that does not exist is already gone.
|
|
314
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') removed = false;
|
|
280
315
|
}
|
|
281
316
|
}
|
|
317
|
+
return removed;
|
|
282
318
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ToolName } from '../../mcp/tools/names.js';
|
|
2
|
+
import type { BrowserKind, Capability } from './types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Capabilities each MCP tool needs from the active driver. Tools mapped to an
|
|
6
|
+
* empty list either manage the session itself (start/stop/status) or only read
|
|
7
|
+
* buffers that every driver fills.
|
|
8
|
+
*/
|
|
9
|
+
export const TOOL_CAPABILITIES: Readonly<
|
|
10
|
+
Record<ToolName, readonly Capability[]>
|
|
11
|
+
> = {
|
|
12
|
+
browser_start: [],
|
|
13
|
+
browser_stop: [],
|
|
14
|
+
browser_status: [],
|
|
15
|
+
browser_stop_all: [],
|
|
16
|
+
browser_connect: ['connectExisting'],
|
|
17
|
+
browser_screenshot: ['screenshot'],
|
|
18
|
+
browser_navigate: ['navigation'],
|
|
19
|
+
browser_click: ['input'],
|
|
20
|
+
browser_type: ['input'],
|
|
21
|
+
browser_evaluate: ['evaluate'],
|
|
22
|
+
browser_wait: ['evaluate'],
|
|
23
|
+
browser_page_info: ['evaluate'],
|
|
24
|
+
browser_list_tabs: ['tabs'],
|
|
25
|
+
browser_new_tab: ['tabs'],
|
|
26
|
+
browser_close_tab: ['tabs'],
|
|
27
|
+
browser_switch_tab: ['tabs'],
|
|
28
|
+
browser_tabs: ['tabs'],
|
|
29
|
+
browser_viewport_resize: ['viewport'],
|
|
30
|
+
browser_get_console_logs: ['console'],
|
|
31
|
+
browser_get_errors: ['console'],
|
|
32
|
+
browser_clear_errors: ['console'],
|
|
33
|
+
browser_console: ['console'],
|
|
34
|
+
browser_errors: ['console'],
|
|
35
|
+
browser_snapshot: ['accessibilitySnapshot'],
|
|
36
|
+
browser_find: ['accessibilitySnapshot'],
|
|
37
|
+
browser_fill: ['input'],
|
|
38
|
+
browser_hover: ['input'],
|
|
39
|
+
browser_press_key: ['input'],
|
|
40
|
+
browser_select: ['input'],
|
|
41
|
+
browser_check: ['input'],
|
|
42
|
+
browser_scroll: ['input'],
|
|
43
|
+
browser_network: ['network'],
|
|
44
|
+
browser_styles: ['styles'],
|
|
45
|
+
browser_dialog: ['dialogs'],
|
|
46
|
+
browser_upload: ['uploads'],
|
|
47
|
+
browser_downloads: ['downloads'],
|
|
48
|
+
browser_performance: ['performanceTrace']
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export class CapabilityError extends Error {
|
|
52
|
+
constructor(
|
|
53
|
+
readonly tool: ToolName,
|
|
54
|
+
readonly browser: BrowserKind,
|
|
55
|
+
readonly missing: readonly Capability[]
|
|
56
|
+
) {
|
|
57
|
+
super(
|
|
58
|
+
`${tool} is not supported by ${browser} (missing capabilities: ${missing.join(', ')}).`
|
|
59
|
+
);
|
|
60
|
+
this.name = 'CapabilityError';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function missingCapabilities(
|
|
65
|
+
tool: ToolName,
|
|
66
|
+
capabilities: ReadonlySet<Capability>
|
|
67
|
+
): Capability[] {
|
|
68
|
+
return TOOL_CAPABILITIES[tool].filter((c) => !capabilities.has(c));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function assertToolSupported(
|
|
72
|
+
tool: ToolName,
|
|
73
|
+
driver: { kind: BrowserKind; capabilities: ReadonlySet<Capability> }
|
|
74
|
+
): void {
|
|
75
|
+
const missing = missingCapabilities(tool, driver.capabilities);
|
|
76
|
+
if (missing.length > 0) {
|
|
77
|
+
throw new CapabilityError(tool, driver.kind, missing);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Tools the given capability set cannot run, for status and docs output. */
|
|
82
|
+
export function unsupportedTools(
|
|
83
|
+
capabilities: ReadonlySet<Capability>
|
|
84
|
+
): ToolName[] {
|
|
85
|
+
return (Object.keys(TOOL_CAPABILITIES) as ToolName[]).filter(
|
|
86
|
+
(tool) => missingCapabilities(tool, capabilities).length > 0
|
|
87
|
+
);
|
|
88
|
+
}
|