@phnx-labs/agents-cli 1.18.2 → 1.18.4
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/CHANGELOG.md +88 -0
- package/README.md +14 -7
- package/dist/commands/browser.js +355 -118
- package/dist/commands/plugins.js +58 -14
- package/dist/commands/view.js +16 -7
- package/dist/lib/browser/devices.d.ts +11 -0
- package/dist/lib/browser/devices.js +14 -3
- package/dist/lib/browser/ipc.js +29 -9
- package/dist/lib/browser/profiles.d.ts +23 -1
- package/dist/lib/browser/profiles.js +63 -3
- package/dist/lib/browser/service.d.ts +41 -10
- package/dist/lib/browser/service.js +321 -64
- package/dist/lib/browser/types.d.ts +55 -2
- package/dist/lib/browser/types.js +20 -0
- package/dist/lib/help.js +30 -3
- package/dist/lib/plugin-marketplace.d.ts +93 -0
- package/dist/lib/plugin-marketplace.js +239 -0
- package/dist/lib/plugins.d.ts +25 -13
- package/dist/lib/plugins.js +350 -566
- package/dist/lib/types.d.ts +18 -1
- package/package.json +1 -1
|
@@ -1,16 +1,48 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import { CDPClient, discoverBrowserWsUrl, verifyBrowserIdentity } from './cdp.js';
|
|
4
|
-
import { getProfile, getProfileRuntimeDir, getBrowserRuntimeDir, listProfiles, extractConfiguredPort, } from './profiles.js';
|
|
4
|
+
import { getProfile, getProfileRuntimeDir, getBrowserRuntimeDir, listProfiles, extractConfiguredPort, resolveEndpoint, } from './profiles.js';
|
|
5
5
|
import { killChrome, getRunningChromeInfo, launchBrowser, allocatePort } from './chrome.js';
|
|
6
6
|
import { connectLocal } from './drivers/local.js';
|
|
7
7
|
import { connectSSH } from './drivers/ssh.js';
|
|
8
|
-
import { generateTaskId, generateShortId,
|
|
8
|
+
import { generateTaskId, generateShortId, generateTaskName, } from './types.js';
|
|
9
9
|
import { getRefs, resolveRefToCoords } from './refs.js';
|
|
10
10
|
import { clickAtCoords, hoverAtCoords, scrollAtCoords, typeText, pressKey, focusNode } from './input.js';
|
|
11
11
|
import { typeEditorText } from './editor.js';
|
|
12
12
|
import { detectUploadPattern, uploadToDropTarget, uploadToFileInput, uploadViaFileChooser, } from './upload.js';
|
|
13
13
|
import { emit } from '../events.js';
|
|
14
|
+
/**
|
|
15
|
+
* Read width/height from a JPEG buffer by walking SOF markers. Returns null
|
|
16
|
+
* if the buffer doesn't start with the JPEG SOI marker or no SOF segment is
|
|
17
|
+
* found. We use this on every screenshot so the CLI can surface the actual
|
|
18
|
+
* captured pixel dimensions (which differ from viewport size at non-1x DPR).
|
|
19
|
+
*/
|
|
20
|
+
function readPngDimensions(buf) {
|
|
21
|
+
// PNG signature (8 bytes) + IHDR chunk: length (4) + 'IHDR' (4) + width (4) + height (4)
|
|
22
|
+
if (buf.length < 24)
|
|
23
|
+
return null;
|
|
24
|
+
const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
25
|
+
for (let i = 0; i < 8; i++)
|
|
26
|
+
if (buf[i] !== sig[i])
|
|
27
|
+
return null;
|
|
28
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
29
|
+
}
|
|
30
|
+
function readJpegDimensions(buf) {
|
|
31
|
+
if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8)
|
|
32
|
+
return null;
|
|
33
|
+
let i = 2;
|
|
34
|
+
while (i + 9 < buf.length) {
|
|
35
|
+
if (buf[i] !== 0xff)
|
|
36
|
+
return null;
|
|
37
|
+
const marker = buf[i + 1];
|
|
38
|
+
// SOF0–SOFn carry dimensions, except DHT (0xC4), JPG (0xC8), DAC (0xCC).
|
|
39
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
40
|
+
return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };
|
|
41
|
+
}
|
|
42
|
+
i += 2 + buf.readUInt16BE(i + 2);
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
14
46
|
/**
|
|
15
47
|
* Parse a `targetFilter` string into its kind + value, or return `null`
|
|
16
48
|
* when the input is missing or malformed. Filter syntax:
|
|
@@ -106,39 +138,60 @@ export class BrowserService {
|
|
|
106
138
|
if (!profile) {
|
|
107
139
|
throw new Error(`Profile "${profileName}" not found`);
|
|
108
140
|
}
|
|
109
|
-
|
|
141
|
+
// Pick the endpoint preset. Throws with the candidate list if the user
|
|
142
|
+
// passed an unknown name. The composite identifier `<profile>@<endpoint>`
|
|
143
|
+
// is what the connection map + per-profile runtime dirs are keyed on, so
|
|
144
|
+
// a single YAML profile can run at multiple endpoints concurrently.
|
|
145
|
+
const resolved = resolveEndpoint(profile, opts.endpointName);
|
|
146
|
+
const composite = `${profileName}@${resolved.name}`;
|
|
147
|
+
const effectiveProfile = {
|
|
148
|
+
...profile,
|
|
149
|
+
name: composite,
|
|
150
|
+
binary: resolved.binary,
|
|
151
|
+
targetFilter: resolved.targetFilter,
|
|
152
|
+
};
|
|
153
|
+
let taskName;
|
|
154
|
+
if (opts.taskName) {
|
|
155
|
+
if (this.hasTaskNamed(opts.taskName)) {
|
|
156
|
+
throw new Error(`Task "${opts.taskName}" already exists. Pick a different --task name or stop the existing one first.`);
|
|
157
|
+
}
|
|
158
|
+
taskName = opts.taskName;
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
taskName = this.generateUniqueTaskName();
|
|
162
|
+
}
|
|
110
163
|
const taskId = generateTaskId();
|
|
111
|
-
let conn = this.connections.get(
|
|
112
|
-
let effectiveProfileName =
|
|
164
|
+
let conn = this.connections.get(composite);
|
|
165
|
+
let effectiveProfileName = composite;
|
|
113
166
|
if (conn && conn.electron && conn.tasks.size > 0) {
|
|
114
|
-
if (this.forkingProfiles.has(
|
|
115
|
-
while (this.forkingProfiles.has(
|
|
167
|
+
if (this.forkingProfiles.has(composite)) {
|
|
168
|
+
while (this.forkingProfiles.has(composite)) {
|
|
116
169
|
await new Promise((r) => setTimeout(r, 50));
|
|
117
170
|
}
|
|
118
|
-
const existingFork = this.findAvailableFork(
|
|
171
|
+
const existingFork = this.findAvailableFork(composite);
|
|
119
172
|
if (existingFork) {
|
|
120
173
|
conn = existingFork.conn;
|
|
121
174
|
effectiveProfileName = existingFork.name;
|
|
122
175
|
}
|
|
123
176
|
else {
|
|
124
|
-
throw new Error(`Fork in progress but no available fork found for "${
|
|
177
|
+
throw new Error(`Fork in progress but no available fork found for "${composite}"`);
|
|
125
178
|
}
|
|
126
179
|
}
|
|
127
180
|
else {
|
|
128
|
-
this.forkingProfiles.add(
|
|
181
|
+
this.forkingProfiles.add(composite);
|
|
129
182
|
try {
|
|
130
|
-
const { forkName, connection } = await this.forkElectronProfile(
|
|
183
|
+
const { forkName, connection } = await this.forkElectronProfile(effectiveProfile);
|
|
131
184
|
conn = connection;
|
|
132
185
|
effectiveProfileName = forkName;
|
|
133
186
|
}
|
|
134
187
|
finally {
|
|
135
|
-
this.forkingProfiles.delete(
|
|
188
|
+
this.forkingProfiles.delete(composite);
|
|
136
189
|
}
|
|
137
190
|
}
|
|
138
191
|
}
|
|
139
192
|
else if (!conn) {
|
|
140
|
-
conn = await this.connectProfile(
|
|
141
|
-
this.connections.set(
|
|
193
|
+
conn = await this.connectProfile(effectiveProfile, resolved.target);
|
|
194
|
+
this.connections.set(composite, conn);
|
|
142
195
|
}
|
|
143
196
|
const task = {
|
|
144
197
|
id: taskId,
|
|
@@ -178,25 +231,7 @@ export class BrowserService {
|
|
|
178
231
|
const result = await this.navigate(taskName, opts.url, effectiveProfileName);
|
|
179
232
|
tabId = result.tabId;
|
|
180
233
|
}
|
|
181
|
-
return { task: taskId, name: taskName, tabId };
|
|
182
|
-
}
|
|
183
|
-
/**
|
|
184
|
-
* Launch (or attach to) the profile's browser without creating a task. Used by
|
|
185
|
-
* `agents browser profiles launch <name>` so users can warm up the browser —
|
|
186
|
-
* including the first-run onboarding flow — before any automation starts.
|
|
187
|
-
*/
|
|
188
|
-
async launchProfile(profileName) {
|
|
189
|
-
const profile = await getProfile(profileName);
|
|
190
|
-
if (!profile) {
|
|
191
|
-
throw new Error(`Profile "${profileName}" not found`);
|
|
192
|
-
}
|
|
193
|
-
let conn = this.connections.get(profileName);
|
|
194
|
-
if (!conn) {
|
|
195
|
-
conn = await this.connectProfile(profile);
|
|
196
|
-
this.connections.set(profileName, conn);
|
|
197
|
-
}
|
|
198
|
-
emit('browser.launch', { profile: profileName, task: '', pid: conn.pid });
|
|
199
|
-
return { port: conn.port, pid: conn.pid };
|
|
234
|
+
return { task: taskId, name: taskName, tabId, profile: effectiveProfileName };
|
|
200
235
|
}
|
|
201
236
|
async stop(taskName) {
|
|
202
237
|
for (const [profileName, conn] of this.connections) {
|
|
@@ -455,7 +490,7 @@ export class BrowserService {
|
|
|
455
490
|
}
|
|
456
491
|
return result.result.value;
|
|
457
492
|
}
|
|
458
|
-
async screenshot(taskId, tabHint, outputPath) {
|
|
493
|
+
async screenshot(taskId, tabHint, outputPath, quality = 'compressed') {
|
|
459
494
|
const { conn, task } = await this.findTask(taskId);
|
|
460
495
|
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
461
496
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
@@ -464,22 +499,201 @@ export class BrowserService {
|
|
|
464
499
|
throw new Error(`Tab ${shortId} not found`);
|
|
465
500
|
}
|
|
466
501
|
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
467
|
-
|
|
468
|
-
let
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
502
|
+
let buffer;
|
|
503
|
+
let extension;
|
|
504
|
+
if (quality === 'raw') {
|
|
505
|
+
// Pixel-faithful PNG, no downscale. For archived QA evidence where
|
|
506
|
+
// lossy JPEG would hide rendering bugs. Files run 0.5–3 MB.
|
|
507
|
+
const { data } = (await conn.cdp.send('Page.captureScreenshot', { format: 'png' }, sessionId));
|
|
508
|
+
buffer = Buffer.from(data, 'base64');
|
|
509
|
+
extension = 'png';
|
|
510
|
+
}
|
|
511
|
+
else {
|
|
512
|
+
// Default: JPEG quality 70, then iteratively downscale to keep the
|
|
513
|
+
// file under 100 KB so chat-injected screenshots stay token-cheap.
|
|
514
|
+
const { data } = (await conn.cdp.send('Page.captureScreenshot', { format: 'jpeg', quality: 70 }, sessionId));
|
|
515
|
+
buffer = Buffer.from(data, 'base64');
|
|
516
|
+
const MAX_SIZE = 100 * 1024;
|
|
517
|
+
if (buffer.length > MAX_SIZE) {
|
|
518
|
+
let q = 50;
|
|
519
|
+
while (buffer.length > MAX_SIZE && q > 10) {
|
|
520
|
+
const { data: resized } = (await conn.cdp.send('Page.captureScreenshot', { format: 'jpeg', quality: q }, sessionId));
|
|
521
|
+
buffer = Buffer.from(resized, 'base64');
|
|
522
|
+
q -= 10;
|
|
523
|
+
}
|
|
476
524
|
}
|
|
525
|
+
extension = 'jpg';
|
|
477
526
|
}
|
|
478
527
|
const sessionsDir = path.join(getBrowserRuntimeDir(), 'sessions', task.name);
|
|
479
|
-
const finalPath = outputPath || path.join(sessionsDir, `${Date.now()}
|
|
528
|
+
const finalPath = outputPath || path.join(sessionsDir, `${Date.now()}.${extension}`);
|
|
480
529
|
await fs.promises.mkdir(path.dirname(finalPath), { recursive: true });
|
|
481
530
|
await fs.promises.writeFile(finalPath, buffer);
|
|
482
|
-
|
|
531
|
+
const dims = (extension === 'png' ? readPngDimensions(buffer) : readJpegDimensions(buffer)) ??
|
|
532
|
+
{ width: 0, height: 0 };
|
|
533
|
+
return { path: finalPath, bytes: buffer.length, width: dims.width, height: dims.height };
|
|
534
|
+
}
|
|
535
|
+
// ─── Recording ──────────────────────────────────────────────────────────────
|
|
536
|
+
//
|
|
537
|
+
// CDP `Page.startScreencast` emits a JPEG frame per `everyNthFrame`. We pipe
|
|
538
|
+
// those frames into ffmpeg's stdin (image2pipe) and encode to a webm/vp9 file
|
|
539
|
+
// under `sessions/<task>/recordings/`. A background watcher enforces the
|
|
540
|
+
// duration + size caps so a forgotten recording can't fill the disk.
|
|
541
|
+
recordings = new Map();
|
|
542
|
+
async recordStart(taskId, tabHint, opts = {}) {
|
|
543
|
+
if (this.recordings.has(taskId)) {
|
|
544
|
+
throw new Error(`Task "${taskId}" is already recording. Call record stop first.`);
|
|
545
|
+
}
|
|
546
|
+
const { conn, task } = await this.findTask(taskId);
|
|
547
|
+
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
548
|
+
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
549
|
+
const target = await this.getTarget(conn, cdpTargetId);
|
|
550
|
+
if (!target)
|
|
551
|
+
throw new Error(`Tab ${shortId} not found`);
|
|
552
|
+
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
553
|
+
const fps = opts.fps ?? 5;
|
|
554
|
+
const durationSec = opts.duration ?? 60;
|
|
555
|
+
const maxMb = opts.maxMb ?? 25;
|
|
556
|
+
if (fps < 1 || fps > 30)
|
|
557
|
+
throw new Error('--fps must be between 1 and 30');
|
|
558
|
+
if (durationSec < 1 || durationSec > 3600)
|
|
559
|
+
throw new Error('--duration must be between 1 and 3600 seconds');
|
|
560
|
+
if (maxMb < 1 || maxMb > 500)
|
|
561
|
+
throw new Error('--max-mb must be between 1 and 500');
|
|
562
|
+
const recordingsDir = path.join(getBrowserRuntimeDir(), 'sessions', task.name, 'recordings');
|
|
563
|
+
await fs.promises.mkdir(recordingsDir, { recursive: true });
|
|
564
|
+
const outputPath = path.join(recordingsDir, `${Date.now()}.webm`);
|
|
565
|
+
// Resolve ffmpeg lazily so non-recording paths don't pay the import cost.
|
|
566
|
+
const { spawn } = await import('child_process');
|
|
567
|
+
const ffmpeg = spawn('ffmpeg', [
|
|
568
|
+
'-loglevel', 'error',
|
|
569
|
+
'-f', 'image2pipe',
|
|
570
|
+
'-framerate', String(fps),
|
|
571
|
+
'-i', '-',
|
|
572
|
+
'-c:v', 'libvpx-vp9',
|
|
573
|
+
'-b:v', '1M',
|
|
574
|
+
'-pix_fmt', 'yuv420p',
|
|
575
|
+
'-y',
|
|
576
|
+
outputPath,
|
|
577
|
+
], { stdio: ['pipe', 'ignore', 'pipe'] });
|
|
578
|
+
// Wait for the spawn to confirm (or fail) before we wire CDP frames into a
|
|
579
|
+
// dead pipe. ENOENT from a missing ffmpeg surfaces here as a real error
|
|
580
|
+
// instead of a silently empty .webm.
|
|
581
|
+
await new Promise((resolve, reject) => {
|
|
582
|
+
const onError = (err) => {
|
|
583
|
+
ffmpeg.off('spawn', onSpawn);
|
|
584
|
+
if (err.code === 'ENOENT') {
|
|
585
|
+
reject(new Error('ffmpeg not found on PATH — install via `brew install ffmpeg`'));
|
|
586
|
+
}
|
|
587
|
+
else {
|
|
588
|
+
reject(err);
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
const onSpawn = () => {
|
|
592
|
+
ffmpeg.off('error', onError);
|
|
593
|
+
resolve();
|
|
594
|
+
};
|
|
595
|
+
ffmpeg.once('error', onError);
|
|
596
|
+
ffmpeg.once('spawn', onSpawn);
|
|
597
|
+
});
|
|
598
|
+
// Surface ffmpeg's own diagnostics (encoder error, etc.) in case stderr
|
|
599
|
+
// arrives after spawn.
|
|
600
|
+
ffmpeg.stderr?.on('data', () => { });
|
|
601
|
+
ffmpeg.on('error', () => { });
|
|
602
|
+
// 30 fps is CDP's screencast cap; everyNthFrame = round(30/fps).
|
|
603
|
+
const everyNthFrame = Math.max(1, Math.round(30 / fps));
|
|
604
|
+
await conn.cdp.send('Page.startScreencast', { format: 'jpeg', quality: 60, everyNthFrame }, sessionId);
|
|
605
|
+
const frameHandler = (params) => {
|
|
606
|
+
const p = params;
|
|
607
|
+
try {
|
|
608
|
+
ffmpeg.stdin?.write(Buffer.from(p.data, 'base64'));
|
|
609
|
+
}
|
|
610
|
+
catch {
|
|
611
|
+
// ffmpeg exited; ignore writes
|
|
612
|
+
}
|
|
613
|
+
// Must ack every frame or CDP stops sending.
|
|
614
|
+
conn.cdp.send('Page.screencastFrameAck', { sessionId: p.sessionId }, sessionId).catch(() => { });
|
|
615
|
+
};
|
|
616
|
+
conn.cdp.on('Page.screencastFrame', frameHandler);
|
|
617
|
+
const durationMs = durationSec * 1000;
|
|
618
|
+
const maxBytes = maxMb * 1024 * 1024;
|
|
619
|
+
const state = {
|
|
620
|
+
outputPath,
|
|
621
|
+
startedAt: Date.now(),
|
|
622
|
+
fps,
|
|
623
|
+
durationMs,
|
|
624
|
+
maxBytes,
|
|
625
|
+
ffmpeg,
|
|
626
|
+
sessionId,
|
|
627
|
+
conn,
|
|
628
|
+
frameHandler,
|
|
629
|
+
durationTimer: setTimeout(() => {
|
|
630
|
+
this.recordStop(taskId, 'duration-cap').catch(() => { });
|
|
631
|
+
}, durationMs),
|
|
632
|
+
sizeCheckInterval: setInterval(async () => {
|
|
633
|
+
try {
|
|
634
|
+
const st = await fs.promises.stat(outputPath);
|
|
635
|
+
if (st.size >= maxBytes) {
|
|
636
|
+
await this.recordStop(taskId, 'size-cap');
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
catch {
|
|
640
|
+
// File may not exist yet
|
|
641
|
+
}
|
|
642
|
+
}, 1000),
|
|
643
|
+
};
|
|
644
|
+
this.recordings.set(taskId, state);
|
|
645
|
+
return { path: outputPath, fps, durationCapSec: durationSec, maxMb };
|
|
646
|
+
}
|
|
647
|
+
async recordStop(taskId, reason = 'manual') {
|
|
648
|
+
const rec = this.recordings.get(taskId);
|
|
649
|
+
if (!rec) {
|
|
650
|
+
throw new Error(`Task "${taskId}" is not currently recording`);
|
|
651
|
+
}
|
|
652
|
+
if (rec.stopReason) {
|
|
653
|
+
// Already stopping (e.g. size-cap fired while user also called stop).
|
|
654
|
+
// Wait for in-flight finalize.
|
|
655
|
+
while (this.recordings.has(taskId)) {
|
|
656
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
rec.stopReason = reason;
|
|
660
|
+
clearTimeout(rec.durationTimer);
|
|
661
|
+
clearInterval(rec.sizeCheckInterval);
|
|
662
|
+
rec.conn.cdp.off('Page.screencastFrame', rec.frameHandler);
|
|
663
|
+
try {
|
|
664
|
+
await rec.conn.cdp.send('Page.stopScreencast', {}, rec.sessionId);
|
|
665
|
+
}
|
|
666
|
+
catch {
|
|
667
|
+
// session may already be gone
|
|
668
|
+
}
|
|
669
|
+
// Close ffmpeg stdin so it flushes the output file cleanly.
|
|
670
|
+
await new Promise((resolve) => {
|
|
671
|
+
rec.ffmpeg.on('exit', () => resolve());
|
|
672
|
+
try {
|
|
673
|
+
rec.ffmpeg.stdin?.end();
|
|
674
|
+
}
|
|
675
|
+
catch {
|
|
676
|
+
resolve();
|
|
677
|
+
}
|
|
678
|
+
setTimeout(resolve, 5000); // Hard timeout if ffmpeg hangs
|
|
679
|
+
});
|
|
680
|
+
let bytes = 0;
|
|
681
|
+
try {
|
|
682
|
+
const st = await fs.promises.stat(rec.outputPath);
|
|
683
|
+
bytes = st.size;
|
|
684
|
+
}
|
|
685
|
+
catch {
|
|
686
|
+
// ffmpeg may have failed; file missing
|
|
687
|
+
}
|
|
688
|
+
const durationMs = Date.now() - rec.startedAt;
|
|
689
|
+
this.recordings.delete(taskId);
|
|
690
|
+
return { path: rec.outputPath, bytes, durationMs, reason };
|
|
691
|
+
}
|
|
692
|
+
async recordStatus(taskId) {
|
|
693
|
+
const rec = this.recordings.get(taskId);
|
|
694
|
+
if (!rec)
|
|
695
|
+
return { recording: false };
|
|
696
|
+
return { recording: true, path: rec.outputPath, elapsedMs: Date.now() - rec.startedAt };
|
|
483
697
|
}
|
|
484
698
|
refsCache = new Map();
|
|
485
699
|
async refs(taskId, tabHint, opts = {}) {
|
|
@@ -969,6 +1183,34 @@ export class BrowserService {
|
|
|
969
1183
|
return undefined;
|
|
970
1184
|
}
|
|
971
1185
|
async shutdown() {
|
|
1186
|
+
// Drain any in-flight recordings first so we don't orphan ffmpeg processes
|
|
1187
|
+
// or leak the duration/size-check timers when the daemon goes down.
|
|
1188
|
+
for (const [taskId, rec] of this.recordings) {
|
|
1189
|
+
clearTimeout(rec.durationTimer);
|
|
1190
|
+
clearInterval(rec.sizeCheckInterval);
|
|
1191
|
+
try {
|
|
1192
|
+
rec.conn.cdp.off('Page.screencastFrame', rec.frameHandler);
|
|
1193
|
+
}
|
|
1194
|
+
catch { /* socket may be gone */ }
|
|
1195
|
+
try {
|
|
1196
|
+
rec.ffmpeg.stdin?.end();
|
|
1197
|
+
}
|
|
1198
|
+
catch { /* already closed */ }
|
|
1199
|
+
// Give ffmpeg up to 1s to flush; then SIGKILL.
|
|
1200
|
+
const exited = await new Promise((resolve) => {
|
|
1201
|
+
let done = false;
|
|
1202
|
+
rec.ffmpeg.once('exit', () => { done = true; resolve(true); });
|
|
1203
|
+
setTimeout(() => { if (!done)
|
|
1204
|
+
resolve(false); }, 1000);
|
|
1205
|
+
});
|
|
1206
|
+
if (!exited) {
|
|
1207
|
+
try {
|
|
1208
|
+
rec.ffmpeg.kill('SIGKILL');
|
|
1209
|
+
}
|
|
1210
|
+
catch { /* already dead */ }
|
|
1211
|
+
}
|
|
1212
|
+
this.recordings.delete(taskId);
|
|
1213
|
+
}
|
|
972
1214
|
for (const [, conn] of this.connections) {
|
|
973
1215
|
conn.cdp.close();
|
|
974
1216
|
}
|
|
@@ -1007,39 +1249,39 @@ export class BrowserService {
|
|
|
1007
1249
|
this.connections.set(forkName, connection);
|
|
1008
1250
|
return { forkName, connection };
|
|
1009
1251
|
}
|
|
1010
|
-
|
|
1011
|
-
|
|
1252
|
+
/**
|
|
1253
|
+
* Connect to a profile at a specific endpoint preset. The caller has
|
|
1254
|
+
* already resolved the endpoint and built the `effectiveProfile` with
|
|
1255
|
+
* the per-endpoint binary/targetFilter overrides applied; we just use it.
|
|
1256
|
+
*
|
|
1257
|
+
* `effectiveProfile.name` is the composite identifier (`<profile>@<endpoint>`)
|
|
1258
|
+
* so per-endpoint pid/port files don't collide when the same app runs
|
|
1259
|
+
* locally and remotely at the same time.
|
|
1260
|
+
*/
|
|
1261
|
+
async connectProfile(effectiveProfile, target) {
|
|
1262
|
+
const existingInfo = getRunningChromeInfo(effectiveProfile.name);
|
|
1012
1263
|
if (existingInfo) {
|
|
1013
1264
|
const { wsUrl, browser } = await discoverBrowserWsUrl(existingInfo.port);
|
|
1014
|
-
verifyBrowserIdentity(browser,
|
|
1265
|
+
verifyBrowserIdentity(browser, effectiveProfile.browser, existingInfo.port);
|
|
1015
1266
|
const cdp = new CDPClient();
|
|
1016
1267
|
await cdp.connect(wsUrl);
|
|
1017
1268
|
await this.enableDomains(cdp);
|
|
1018
|
-
const tasks = this.loadTaskState(
|
|
1269
|
+
const tasks = this.loadTaskState(effectiveProfile.name);
|
|
1019
1270
|
return {
|
|
1020
1271
|
cdp,
|
|
1021
1272
|
port: existingInfo.port,
|
|
1022
1273
|
pid: existingInfo.pid,
|
|
1023
|
-
electron:
|
|
1024
|
-
targetFilter:
|
|
1274
|
+
electron: effectiveProfile.electron,
|
|
1275
|
+
targetFilter: effectiveProfile.targetFilter,
|
|
1025
1276
|
tasks,
|
|
1026
1277
|
sessionCache: new Map(),
|
|
1027
1278
|
};
|
|
1028
1279
|
}
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
if (conn)
|
|
1033
|
-
return conn;
|
|
1034
|
-
}
|
|
1035
|
-
catch (err) {
|
|
1036
|
-
if (err instanceof Error && err.message.startsWith('Browser identity mismatch')) {
|
|
1037
|
-
throw err;
|
|
1038
|
-
}
|
|
1039
|
-
// Try next endpoint
|
|
1040
|
-
}
|
|
1280
|
+
const conn = await this.connectEndpoint(effectiveProfile, target);
|
|
1281
|
+
if (!conn) {
|
|
1282
|
+
throw new Error(`Could not connect to endpoint ${target} for profile "${effectiveProfile.name}"`);
|
|
1041
1283
|
}
|
|
1042
|
-
|
|
1284
|
+
return conn;
|
|
1043
1285
|
}
|
|
1044
1286
|
async connectEndpoint(profile, endpoint) {
|
|
1045
1287
|
const url = new URL(endpoint);
|
|
@@ -1143,6 +1385,21 @@ export class BrowserService {
|
|
|
1143
1385
|
conn.windowId = result.targetId;
|
|
1144
1386
|
return result.targetId;
|
|
1145
1387
|
}
|
|
1388
|
+
hasTaskNamed(name) {
|
|
1389
|
+
for (const conn of this.connections.values()) {
|
|
1390
|
+
if (conn.tasks.has(name))
|
|
1391
|
+
return true;
|
|
1392
|
+
}
|
|
1393
|
+
return false;
|
|
1394
|
+
}
|
|
1395
|
+
generateUniqueTaskName() {
|
|
1396
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
1397
|
+
const candidate = generateTaskName();
|
|
1398
|
+
if (!this.hasTaskNamed(candidate))
|
|
1399
|
+
return candidate;
|
|
1400
|
+
}
|
|
1401
|
+
throw new Error('Could not generate unique task name after 8 attempts');
|
|
1402
|
+
}
|
|
1146
1403
|
async findTask(taskId, profileName) {
|
|
1147
1404
|
if (profileName) {
|
|
1148
1405
|
const conn = this.connections.get(profileName);
|
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
export type BrowserType = 'chrome' | 'comet' | 'chromium' | 'brave' | 'edge' | 'custom';
|
|
2
|
+
/**
|
|
3
|
+
* A single named endpoint preset within a profile. Lets one profile cover
|
|
4
|
+
* the local + remote variants of the same app (e.g. Rush on this Mac vs.
|
|
5
|
+
* Rush on mac-mini) instead of forcing two parallel profiles.
|
|
6
|
+
*
|
|
7
|
+
* Per-endpoint overrides take precedence over profile-level fields.
|
|
8
|
+
*/
|
|
9
|
+
export interface EndpointPreset {
|
|
10
|
+
/** CDP URL — `cdp://host:port` or `ssh://host?port=N` */
|
|
11
|
+
target: string;
|
|
12
|
+
/** Override the profile-level binary (e.g. mac-mini has no local binary). */
|
|
13
|
+
binary?: string;
|
|
14
|
+
/** Override the profile-level targetFilter (Electron app builds may diverge). */
|
|
15
|
+
targetFilter?: string;
|
|
16
|
+
}
|
|
2
17
|
export interface BrowserProfile {
|
|
3
18
|
name: string;
|
|
4
19
|
description?: string;
|
|
@@ -10,7 +25,14 @@ export interface BrowserProfile {
|
|
|
10
25
|
* represents the visible UI for Electron apps with multiple WebContents.
|
|
11
26
|
*/
|
|
12
27
|
targetFilter?: string;
|
|
13
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Endpoint presets. Accepts two shapes for backward compatibility:
|
|
30
|
+
* - Legacy: `string[]` of CDP URLs; first entry is the default.
|
|
31
|
+
* - New: `{ [presetName]: EndpointPreset }`, with optional `defaultEndpoint`.
|
|
32
|
+
* Normalize via `resolveEndpoint(profile, name?)` instead of reading directly.
|
|
33
|
+
*/
|
|
34
|
+
endpoints: string[] | Record<string, EndpointPreset>;
|
|
35
|
+
defaultEndpoint?: string;
|
|
14
36
|
chrome?: ChromeOptions;
|
|
15
37
|
secrets?: string;
|
|
16
38
|
viewport?: {
|
|
@@ -83,7 +105,7 @@ export interface HistoricalTask {
|
|
|
83
105
|
domains: string[];
|
|
84
106
|
tabCount: number;
|
|
85
107
|
}
|
|
86
|
-
export type IPCAction = 'start' | '
|
|
108
|
+
export type IPCAction = 'start' | 'record-start' | 'record-stop' | 'done' | 'stop' | 'status' | 'history' | 'navigate' | 'tab-add' | 'tab-focus' | 'tab-close' | 'tab-list' | 'evaluate' | 'screenshot' | 'refs' | 'click' | 'type' | 'press' | 'hover' | 'scroll' | 'set-viewport' | 'set-device' | 'console' | 'errors' | 'requests' | 'response-body' | 'wait' | 'set-download-path' | 'wait-download' | 'upload';
|
|
87
109
|
export interface IPCRequest {
|
|
88
110
|
action: IPCAction;
|
|
89
111
|
task?: string;
|
|
@@ -119,6 +141,22 @@ export interface IPCRequest {
|
|
|
119
141
|
files?: string[];
|
|
120
142
|
trigger?: number;
|
|
121
143
|
uploadMode?: 'auto' | 'input' | 'drop' | 'chooser';
|
|
144
|
+
quality?: 'compressed' | 'raw';
|
|
145
|
+
endpoint?: string;
|
|
146
|
+
fps?: number;
|
|
147
|
+
duration?: number;
|
|
148
|
+
maxMb?: number;
|
|
149
|
+
}
|
|
150
|
+
/** Subset of IPCResponse describing a recording start result. */
|
|
151
|
+
export interface RecordStartFields {
|
|
152
|
+
fps?: number;
|
|
153
|
+
durationCapSec?: number;
|
|
154
|
+
maxMb?: number;
|
|
155
|
+
}
|
|
156
|
+
/** Subset of IPCResponse describing a recording stop result. */
|
|
157
|
+
export interface RecordStopFields {
|
|
158
|
+
durationMs?: number;
|
|
159
|
+
stopReason?: 'manual' | 'duration-cap' | 'size-cap';
|
|
122
160
|
}
|
|
123
161
|
export interface IPCResponse {
|
|
124
162
|
ok: boolean;
|
|
@@ -131,10 +169,18 @@ export interface IPCResponse {
|
|
|
131
169
|
history?: HistoricalTask[];
|
|
132
170
|
result?: unknown;
|
|
133
171
|
path?: string;
|
|
172
|
+
bytes?: number;
|
|
173
|
+
width?: number;
|
|
174
|
+
height?: number;
|
|
134
175
|
refs?: string;
|
|
135
176
|
nodes?: RefNodeJson[];
|
|
136
177
|
port?: number;
|
|
137
178
|
pid?: number;
|
|
179
|
+
fps?: number;
|
|
180
|
+
durationCapSec?: number;
|
|
181
|
+
maxMb?: number;
|
|
182
|
+
durationMs?: number;
|
|
183
|
+
stopReason?: 'manual' | 'duration-cap' | 'size-cap';
|
|
138
184
|
logs?: ConsoleEntry[];
|
|
139
185
|
errors?: ErrorEntry[];
|
|
140
186
|
requests?: NetworkRequest[];
|
|
@@ -183,3 +229,10 @@ export declare function isValidTaskId(id: string): boolean;
|
|
|
183
229
|
export declare function generateTaskId(): string;
|
|
184
230
|
export declare function generateShortId(): string;
|
|
185
231
|
export declare function generateFunName(): string;
|
|
232
|
+
/**
|
|
233
|
+
* Auto-generated task name: `<adjective>-<noun>-<noun>-<hex8>`, e.g.
|
|
234
|
+
* `swift-crab-falcon-a3f92b1c`. Three English words make it memorable and
|
|
235
|
+
* easy to read; 32 bits of hex give every spawned task enough entropy that
|
|
236
|
+
* parallel agents never collide on the daemon side.
|
|
237
|
+
*/
|
|
238
|
+
export declare function generateTaskName(): string;
|
|
@@ -11,13 +11,33 @@ export function generateShortId() {
|
|
|
11
11
|
const ADJECTIVES = [
|
|
12
12
|
'swift', 'cosmic', 'jolly', 'quiet', 'bold', 'bright', 'calm', 'eager',
|
|
13
13
|
'golden', 'happy', 'keen', 'lucky', 'noble', 'proud', 'quick', 'royal',
|
|
14
|
+
'silver', 'amber', 'crimson', 'misty', 'sunny', 'gentle', 'wild', 'brave',
|
|
15
|
+
'merry', 'sleek', 'wise', 'fierce', 'curious', 'humble', 'spry', 'witty',
|
|
14
16
|
];
|
|
15
17
|
const NOUNS = [
|
|
16
18
|
'falcon', 'comet', 'tiger', 'nebula', 'phoenix', 'river', 'summit', 'wave',
|
|
17
19
|
'aurora', 'breeze', 'crystal', 'dragon', 'ember', 'forest', 'glacier', 'harbor',
|
|
20
|
+
'crab', 'otter', 'hawk', 'fox', 'wolf', 'panda', 'lynx', 'raven',
|
|
21
|
+
'meadow', 'canyon', 'valley', 'orchid', 'cedar', 'thistle', 'lotus', 'briar',
|
|
18
22
|
];
|
|
19
23
|
export function generateFunName() {
|
|
20
24
|
const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
|
|
21
25
|
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
|
|
22
26
|
return `${adj}-${noun}`;
|
|
23
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Auto-generated task name: `<adjective>-<noun>-<noun>-<hex8>`, e.g.
|
|
30
|
+
* `swift-crab-falcon-a3f92b1c`. Three English words make it memorable and
|
|
31
|
+
* easy to read; 32 bits of hex give every spawned task enough entropy that
|
|
32
|
+
* parallel agents never collide on the daemon side.
|
|
33
|
+
*/
|
|
34
|
+
export function generateTaskName() {
|
|
35
|
+
const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
|
|
36
|
+
const noun1 = NOUNS[Math.floor(Math.random() * NOUNS.length)];
|
|
37
|
+
let noun2 = NOUNS[Math.floor(Math.random() * NOUNS.length)];
|
|
38
|
+
while (noun2 === noun1) {
|
|
39
|
+
noun2 = NOUNS[Math.floor(Math.random() * NOUNS.length)];
|
|
40
|
+
}
|
|
41
|
+
const hex8 = crypto.randomUUID().replace(/-/g, '').slice(0, 8);
|
|
42
|
+
return `${adj}-${noun1}-${noun2}-${hex8}`;
|
|
43
|
+
}
|
package/dist/lib/help.js
CHANGED
|
@@ -23,12 +23,36 @@ function formatHelpCommandsFirst(cmd, helper) {
|
|
|
23
23
|
function formatList(textArray) {
|
|
24
24
|
return textArray.join('\n').replace(/^/gm, ' '.repeat(itemIndentWidth));
|
|
25
25
|
}
|
|
26
|
-
|
|
26
|
+
// Drop arguments flagged as hidden (deprecation / compat slots) from both
|
|
27
|
+
// the Usage line and the Arguments section. Commander v12's Argument lacks
|
|
28
|
+
// hideHelp(), so we read a custom `hidden` field that callers set directly.
|
|
29
|
+
const isHidden = (a) => a.hidden === true;
|
|
30
|
+
const registeredArgs = cmd.registeredArguments ?? [];
|
|
31
|
+
const parentNames = [];
|
|
32
|
+
for (let p = cmd.parent; p; p = p.parent)
|
|
33
|
+
parentNames.unshift(p.name());
|
|
34
|
+
const parentPrefix = parentNames.length > 0 ? parentNames.join(' ') + ' ' : '';
|
|
35
|
+
const visibleArgTokens = registeredArgs
|
|
36
|
+
.filter((a) => !isHidden(a))
|
|
37
|
+
.map((a) => {
|
|
38
|
+
const n = a.name() + (a.variadic ? '...' : '');
|
|
39
|
+
return a.required ? `<${n}>` : `[${n}]`;
|
|
40
|
+
})
|
|
41
|
+
.join(' ');
|
|
42
|
+
// commander always exposes -h/--help, so every command effectively has options.
|
|
43
|
+
// Order matches commander's default: name [options] <args> [command].
|
|
44
|
+
const argsToken = visibleArgTokens ? ` ${visibleArgTokens}` : '';
|
|
45
|
+
const commandToken = cmd.commands.length > 0 ? ' [command]' : '';
|
|
46
|
+
const usageLine = `${parentPrefix}${cmd.name()} [options]${argsToken}${commandToken}`;
|
|
47
|
+
let output = [`Usage: ${usageLine}`, ''];
|
|
27
48
|
const commandDescription = helper.commandDescription(cmd);
|
|
28
49
|
if (commandDescription.length > 0) {
|
|
29
50
|
output = output.concat([helper.wrap(commandDescription, helpWidth, 0), '']);
|
|
30
51
|
}
|
|
31
|
-
const argumentList = helper
|
|
52
|
+
const argumentList = helper
|
|
53
|
+
.visibleArguments(cmd)
|
|
54
|
+
.filter((a) => !isHidden(a))
|
|
55
|
+
.map((argument) => {
|
|
32
56
|
return formatItem(helper.argumentTerm(argument), helper.argumentDescription(argument));
|
|
33
57
|
});
|
|
34
58
|
if (argumentList.length > 0) {
|
|
@@ -36,9 +60,12 @@ function formatHelpCommandsFirst(cmd, helper) {
|
|
|
36
60
|
}
|
|
37
61
|
const visibleCommands = helper.visibleCommands(cmd);
|
|
38
62
|
const subcommandTermNoAlias = (sub) => {
|
|
39
|
-
// Mirror commander's default subcommandTerm but drop the |alias suffix
|
|
63
|
+
// Mirror commander's default subcommandTerm but drop the |alias suffix and
|
|
64
|
+
// skip arguments marked as hidden (Argument#hideHelp()), so deprecation /
|
|
65
|
+
// compatibility slots don't pollute the usage line.
|
|
40
66
|
const argList = sub.registeredArguments ?? [];
|
|
41
67
|
const args = argList
|
|
68
|
+
.filter((a) => !a.hidden)
|
|
42
69
|
.map((a) => {
|
|
43
70
|
const n = a.name() + (a.variadic ? '...' : '');
|
|
44
71
|
return a.required ? `<${n}>` : `[${n}]`;
|