@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.
@@ -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, generateFunName, } from './types.js';
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
- const taskName = opts.taskName || generateFunName();
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(profileName);
112
- let effectiveProfileName = profileName;
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(profileName)) {
115
- while (this.forkingProfiles.has(profileName)) {
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(profileName);
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 "${profileName}"`);
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(profileName);
181
+ this.forkingProfiles.add(composite);
129
182
  try {
130
- const { forkName, connection } = await this.forkElectronProfile(profile);
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(profileName);
188
+ this.forkingProfiles.delete(composite);
136
189
  }
137
190
  }
138
191
  }
139
192
  else if (!conn) {
140
- conn = await this.connectProfile(profile);
141
- this.connections.set(profileName, conn);
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
- const { data } = (await conn.cdp.send('Page.captureScreenshot', { format: 'jpeg', quality: 70 }, sessionId));
468
- let buffer = Buffer.from(data, 'base64');
469
- const MAX_SIZE = 100 * 1024;
470
- if (buffer.length > MAX_SIZE) {
471
- let quality = 50;
472
- while (buffer.length > MAX_SIZE && quality > 10) {
473
- const { data: resized } = (await conn.cdp.send('Page.captureScreenshot', { format: 'jpeg', quality }, sessionId));
474
- buffer = Buffer.from(resized, 'base64');
475
- quality -= 10;
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()}.jpg`);
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
- return finalPath;
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
- async connectProfile(profile) {
1011
- const existingInfo = getRunningChromeInfo(profile.name);
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, profile.browser, existingInfo.port);
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(profile.name);
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: profile.electron,
1024
- targetFilter: profile.targetFilter,
1274
+ electron: effectiveProfile.electron,
1275
+ targetFilter: effectiveProfile.targetFilter,
1025
1276
  tasks,
1026
1277
  sessionCache: new Map(),
1027
1278
  };
1028
1279
  }
1029
- for (const endpoint of profile.endpoints) {
1030
- try {
1031
- const conn = await this.connectEndpoint(profile, endpoint);
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
- throw new Error(`Could not connect to any endpoint for profile "${profile.name}"`);
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
- endpoints: string[];
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' | 'launch-profile' | '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';
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
- let output = [`Usage: ${helper.commandUsage(cmd)}`, ''];
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.visibleArguments(cmd).map((argument) => {
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}]`;