@phnx-labs/agents-cli 1.18.3 → 1.18.5

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,52 @@
1
1
  import * as fs from 'fs';
2
+ import * as os from 'os';
2
3
  import * as path from 'path';
4
+ import { execFile } from 'child_process';
5
+ import { promisify } from 'util';
3
6
  import { CDPClient, discoverBrowserWsUrl, verifyBrowserIdentity } from './cdp.js';
4
- import { getProfile, getProfileRuntimeDir, getBrowserRuntimeDir, listProfiles, extractConfiguredPort, } from './profiles.js';
7
+ import { getProfile, getProfileRuntimeDir, getBrowserRuntimeDir, listProfiles, extractConfiguredPort, resolveEndpoint, } from './profiles.js';
5
8
  import { killChrome, getRunningChromeInfo, launchBrowser, allocatePort } from './chrome.js';
6
9
  import { connectLocal } from './drivers/local.js';
7
10
  import { connectSSH } from './drivers/ssh.js';
8
- import { generateTaskId, generateShortId, generateFunName, } from './types.js';
11
+ import { clearProfileRuntime } from './runtime-state.js';
12
+ import { generateTaskId, generateShortId, generateTaskName, } from './types.js';
9
13
  import { getRefs, resolveRefToCoords } from './refs.js';
10
14
  import { clickAtCoords, hoverAtCoords, scrollAtCoords, typeText, pressKey, focusNode } from './input.js';
11
15
  import { typeEditorText } from './editor.js';
12
16
  import { detectUploadPattern, uploadToDropTarget, uploadToFileInput, uploadViaFileChooser, } from './upload.js';
13
17
  import { emit } from '../events.js';
18
+ /**
19
+ * Read width/height from a JPEG buffer by walking SOF markers. Returns null
20
+ * if the buffer doesn't start with the JPEG SOI marker or no SOF segment is
21
+ * found. We use this on every screenshot so the CLI can surface the actual
22
+ * captured pixel dimensions (which differ from viewport size at non-1x DPR).
23
+ */
24
+ function readPngDimensions(buf) {
25
+ // PNG signature (8 bytes) + IHDR chunk: length (4) + 'IHDR' (4) + width (4) + height (4)
26
+ if (buf.length < 24)
27
+ return null;
28
+ const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
29
+ for (let i = 0; i < 8; i++)
30
+ if (buf[i] !== sig[i])
31
+ return null;
32
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
33
+ }
34
+ function readJpegDimensions(buf) {
35
+ if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8)
36
+ return null;
37
+ let i = 2;
38
+ while (i + 9 < buf.length) {
39
+ if (buf[i] !== 0xff)
40
+ return null;
41
+ const marker = buf[i + 1];
42
+ // SOF0–SOFn carry dimensions, except DHT (0xC4), JPG (0xC8), DAC (0xCC).
43
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
44
+ return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };
45
+ }
46
+ i += 2 + buf.readUInt16BE(i + 2);
47
+ }
48
+ return null;
49
+ }
14
50
  /**
15
51
  * Parse a `targetFilter` string into its kind + value, or return `null`
16
52
  * when the input is missing or malformed. Filter syntax:
@@ -92,7 +128,81 @@ export function pickWindowTarget(targets, filter) {
92
128
  return visible;
93
129
  return pages[0];
94
130
  }
131
+ const execFileP = promisify(execFile);
132
+ /**
133
+ * Parse a `--since`/`--until` value. Accepts ISO-8601 absolute timestamps
134
+ * or relative offsets like `30s`, `5m`, `2h`, `1d`.
135
+ */
136
+ export function parseSinceUntil(s) {
137
+ const ms = Date.parse(s);
138
+ if (!isNaN(ms))
139
+ return new Date(ms);
140
+ const m = s.match(/^(\d+)([smhd])$/);
141
+ if (!m)
142
+ throw new Error(`Invalid since/until: ${s}`);
143
+ const n = parseInt(m[1], 10);
144
+ const unitMs = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
145
+ return new Date(Date.now() - n * unitMs[m[2]]);
146
+ }
147
+ async function execSSH(host, cmd) {
148
+ const { stdout } = await execFileP('ssh', [host, cmd], {
149
+ timeout: 10_000,
150
+ maxBuffer: 10_000_000,
151
+ });
152
+ return stdout;
153
+ }
154
+ export function readNewestMatchingFile(dir, prefix, tailLines) {
155
+ let entries;
156
+ try {
157
+ entries = fs.readdirSync(dir);
158
+ }
159
+ catch {
160
+ return '';
161
+ }
162
+ const candidates = entries
163
+ .filter((f) => f.startsWith(prefix) && f.endsWith('.jsonl'))
164
+ .map((f) => ({ f, mtime: fs.statSync(path.join(dir, f)).mtimeMs }))
165
+ .sort((a, b) => b.mtime - a.mtime);
166
+ if (candidates.length === 0)
167
+ return '';
168
+ const lines = fs
169
+ .readFileSync(path.join(dir, candidates[0].f), 'utf8')
170
+ .split('\n')
171
+ .filter(Boolean);
172
+ return lines.slice(-tailLines).join('\n');
173
+ }
174
+ function expandHome(p) {
175
+ if (p.startsWith('~/'))
176
+ return path.join(os.homedir(), p.slice(2));
177
+ if (p === '~')
178
+ return os.homedir();
179
+ return p;
180
+ }
181
+ /**
182
+ * Probe a cached connection before reuse. A WebSocket can quietly transition
183
+ * to CLOSED without anyone noticing — most commonly when the user kills the
184
+ * browser process by hand. `Browser.getVersion` is the lightest CDP call we
185
+ * can make; if it doesn't round-trip within 1s the connection is dead.
186
+ */
187
+ async function isConnHealthy(conn, timeoutMs = 1000) {
188
+ if (!conn.cdp.isOpen)
189
+ return false;
190
+ try {
191
+ await Promise.race([
192
+ conn.cdp.send('Browser.getVersion'),
193
+ new Promise((_, reject) => setTimeout(() => reject(new Error('healthcheck timeout')), timeoutMs)),
194
+ ]);
195
+ return true;
196
+ }
197
+ catch {
198
+ return false;
199
+ }
200
+ }
95
201
  export class BrowserService {
202
+ static SOURCE_PREFIX = {
203
+ 'rush-app': 'rush-app-',
204
+ 'rush-cli': 'rush-cli-',
205
+ };
96
206
  connections = new Map();
97
207
  forkingProfiles = new Set();
98
208
  // Per-task storage for console, errors, network, downloads
@@ -106,39 +216,74 @@ export class BrowserService {
106
216
  if (!profile) {
107
217
  throw new Error(`Profile "${profileName}" not found`);
108
218
  }
109
- const taskName = opts.taskName || generateFunName();
219
+ // Pick the endpoint preset. Throws with the candidate list if the user
220
+ // passed an unknown name. The composite identifier `<profile>@<endpoint>`
221
+ // is what the connection map + per-profile runtime dirs are keyed on, so
222
+ // a single YAML profile can run at multiple endpoints concurrently.
223
+ const resolved = resolveEndpoint(profile, opts.endpointName);
224
+ const composite = `${profileName}@${resolved.name}`;
225
+ const effectiveProfile = {
226
+ ...profile,
227
+ name: composite,
228
+ binary: resolved.binary,
229
+ targetFilter: resolved.targetFilter,
230
+ };
231
+ let taskName;
232
+ if (opts.taskName) {
233
+ if (this.hasTaskNamed(opts.taskName)) {
234
+ throw new Error(`Task "${opts.taskName}" already exists. Pick a different --task name or stop the existing one first.`);
235
+ }
236
+ taskName = opts.taskName;
237
+ }
238
+ else {
239
+ taskName = this.generateUniqueTaskName();
240
+ }
110
241
  const taskId = generateTaskId();
111
- let conn = this.connections.get(profileName);
112
- let effectiveProfileName = profileName;
242
+ let conn = this.connections.get(composite);
243
+ let effectiveProfileName = composite;
244
+ // If we have a cached connection, confirm it's still usable before any
245
+ // caller relies on it. A browser killed externally (Cmd-Q, crash, or a
246
+ // user clearing the profile by hand) leaves a closed WebSocket here.
247
+ // Without this check the next `cdp.send` throws "CDP connection not
248
+ // open" and the user has no way to recover short of killing the daemon.
249
+ if (conn && !(await isConnHealthy(conn))) {
250
+ try {
251
+ conn.cdp.close();
252
+ }
253
+ catch { /* already closed */ }
254
+ conn.cleanup?.();
255
+ this.connections.delete(composite);
256
+ conn = undefined;
257
+ }
113
258
  if (conn && conn.electron && conn.tasks.size > 0) {
114
- if (this.forkingProfiles.has(profileName)) {
115
- while (this.forkingProfiles.has(profileName)) {
259
+ if (this.forkingProfiles.has(composite)) {
260
+ while (this.forkingProfiles.has(composite)) {
116
261
  await new Promise((r) => setTimeout(r, 50));
117
262
  }
118
- const existingFork = this.findAvailableFork(profileName);
263
+ const existingFork = this.findAvailableFork(composite);
119
264
  if (existingFork) {
120
265
  conn = existingFork.conn;
121
266
  effectiveProfileName = existingFork.name;
122
267
  }
123
268
  else {
124
- throw new Error(`Fork in progress but no available fork found for "${profileName}"`);
269
+ throw new Error(`Fork in progress but no available fork found for "${composite}"`);
125
270
  }
126
271
  }
127
272
  else {
128
- this.forkingProfiles.add(profileName);
273
+ this.forkingProfiles.add(composite);
129
274
  try {
130
- const { forkName, connection } = await this.forkElectronProfile(profile);
275
+ const { forkName, connection } = await this.forkElectronProfile(effectiveProfile);
131
276
  conn = connection;
132
277
  effectiveProfileName = forkName;
133
278
  }
134
279
  finally {
135
- this.forkingProfiles.delete(profileName);
280
+ this.forkingProfiles.delete(composite);
136
281
  }
137
282
  }
138
283
  }
139
284
  else if (!conn) {
140
- conn = await this.connectProfile(profile);
141
- this.connections.set(profileName, conn);
285
+ conn = await this.connectProfile(effectiveProfile, resolved.target);
286
+ this.connections.set(composite, conn);
142
287
  }
143
288
  const task = {
144
289
  id: taskId,
@@ -178,25 +323,7 @@ export class BrowserService {
178
323
  const result = await this.navigate(taskName, opts.url, effectiveProfileName);
179
324
  tabId = result.tabId;
180
325
  }
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 };
326
+ return { task: taskId, name: taskName, tabId, profile: effectiveProfileName };
200
327
  }
201
328
  async stop(taskName) {
202
329
  for (const [profileName, conn] of this.connections) {
@@ -239,6 +366,7 @@ export class BrowserService {
239
366
  if (conn.forkedFrom && conn.tasks.size === 0) {
240
367
  conn.cdp.close();
241
368
  killChrome(conn.pid);
369
+ conn.cleanup?.();
242
370
  this.connections.delete(profileName);
243
371
  }
244
372
  return { ok: true, profile: profileName };
@@ -254,6 +382,7 @@ export class BrowserService {
254
382
  if (conn) {
255
383
  conn.cdp.close();
256
384
  killChrome(conn.pid);
385
+ conn.cleanup?.();
257
386
  this.connections.delete(profileName);
258
387
  }
259
388
  const runtimeDir = getProfileRuntimeDir(profileName);
@@ -455,7 +584,7 @@ export class BrowserService {
455
584
  }
456
585
  return result.result.value;
457
586
  }
458
- async screenshot(taskId, tabHint, outputPath) {
587
+ async screenshot(taskId, tabHint, outputPath, quality = 'compressed') {
459
588
  const { conn, task } = await this.findTask(taskId);
460
589
  const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
461
590
  const cdpTargetId = this.getCdpTargetId(task, shortId);
@@ -464,22 +593,201 @@ export class BrowserService {
464
593
  throw new Error(`Tab ${shortId} not found`);
465
594
  }
466
595
  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;
596
+ let buffer;
597
+ let extension;
598
+ if (quality === 'raw') {
599
+ // Pixel-faithful PNG, no downscale. For archived QA evidence where
600
+ // lossy JPEG would hide rendering bugs. Files run 0.5–3 MB.
601
+ const { data } = (await conn.cdp.send('Page.captureScreenshot', { format: 'png' }, sessionId));
602
+ buffer = Buffer.from(data, 'base64');
603
+ extension = 'png';
604
+ }
605
+ else {
606
+ // Default: JPEG quality 70, then iteratively downscale to keep the
607
+ // file under 100 KB so chat-injected screenshots stay token-cheap.
608
+ const { data } = (await conn.cdp.send('Page.captureScreenshot', { format: 'jpeg', quality: 70 }, sessionId));
609
+ buffer = Buffer.from(data, 'base64');
610
+ const MAX_SIZE = 100 * 1024;
611
+ if (buffer.length > MAX_SIZE) {
612
+ let q = 50;
613
+ while (buffer.length > MAX_SIZE && q > 10) {
614
+ const { data: resized } = (await conn.cdp.send('Page.captureScreenshot', { format: 'jpeg', quality: q }, sessionId));
615
+ buffer = Buffer.from(resized, 'base64');
616
+ q -= 10;
617
+ }
476
618
  }
619
+ extension = 'jpg';
477
620
  }
478
621
  const sessionsDir = path.join(getBrowserRuntimeDir(), 'sessions', task.name);
479
- const finalPath = outputPath || path.join(sessionsDir, `${Date.now()}.jpg`);
622
+ const finalPath = outputPath || path.join(sessionsDir, `${Date.now()}.${extension}`);
480
623
  await fs.promises.mkdir(path.dirname(finalPath), { recursive: true });
481
624
  await fs.promises.writeFile(finalPath, buffer);
482
- return finalPath;
625
+ const dims = (extension === 'png' ? readPngDimensions(buffer) : readJpegDimensions(buffer)) ??
626
+ { width: 0, height: 0 };
627
+ return { path: finalPath, bytes: buffer.length, width: dims.width, height: dims.height };
628
+ }
629
+ // ─── Recording ──────────────────────────────────────────────────────────────
630
+ //
631
+ // CDP `Page.startScreencast` emits a JPEG frame per `everyNthFrame`. We pipe
632
+ // those frames into ffmpeg's stdin (image2pipe) and encode to a webm/vp9 file
633
+ // under `sessions/<task>/recordings/`. A background watcher enforces the
634
+ // duration + size caps so a forgotten recording can't fill the disk.
635
+ recordings = new Map();
636
+ async recordStart(taskId, tabHint, opts = {}) {
637
+ if (this.recordings.has(taskId)) {
638
+ throw new Error(`Task "${taskId}" is already recording. Call record stop first.`);
639
+ }
640
+ const { conn, task } = await this.findTask(taskId);
641
+ const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
642
+ const cdpTargetId = this.getCdpTargetId(task, shortId);
643
+ const target = await this.getTarget(conn, cdpTargetId);
644
+ if (!target)
645
+ throw new Error(`Tab ${shortId} not found`);
646
+ const sessionId = await this.getSessionId(conn, target.targetId);
647
+ const fps = opts.fps ?? 5;
648
+ const durationSec = opts.duration ?? 60;
649
+ const maxMb = opts.maxMb ?? 25;
650
+ if (fps < 1 || fps > 30)
651
+ throw new Error('--fps must be between 1 and 30');
652
+ if (durationSec < 1 || durationSec > 3600)
653
+ throw new Error('--duration must be between 1 and 3600 seconds');
654
+ if (maxMb < 1 || maxMb > 500)
655
+ throw new Error('--max-mb must be between 1 and 500');
656
+ const recordingsDir = path.join(getBrowserRuntimeDir(), 'sessions', task.name, 'recordings');
657
+ await fs.promises.mkdir(recordingsDir, { recursive: true });
658
+ const outputPath = path.join(recordingsDir, `${Date.now()}.webm`);
659
+ // Resolve ffmpeg lazily so non-recording paths don't pay the import cost.
660
+ const { spawn } = await import('child_process');
661
+ const ffmpeg = spawn('ffmpeg', [
662
+ '-loglevel', 'error',
663
+ '-f', 'image2pipe',
664
+ '-framerate', String(fps),
665
+ '-i', '-',
666
+ '-c:v', 'libvpx-vp9',
667
+ '-b:v', '1M',
668
+ '-pix_fmt', 'yuv420p',
669
+ '-y',
670
+ outputPath,
671
+ ], { stdio: ['pipe', 'ignore', 'pipe'] });
672
+ // Wait for the spawn to confirm (or fail) before we wire CDP frames into a
673
+ // dead pipe. ENOENT from a missing ffmpeg surfaces here as a real error
674
+ // instead of a silently empty .webm.
675
+ await new Promise((resolve, reject) => {
676
+ const onError = (err) => {
677
+ ffmpeg.off('spawn', onSpawn);
678
+ if (err.code === 'ENOENT') {
679
+ reject(new Error('ffmpeg not found on PATH — install via `brew install ffmpeg`'));
680
+ }
681
+ else {
682
+ reject(err);
683
+ }
684
+ };
685
+ const onSpawn = () => {
686
+ ffmpeg.off('error', onError);
687
+ resolve();
688
+ };
689
+ ffmpeg.once('error', onError);
690
+ ffmpeg.once('spawn', onSpawn);
691
+ });
692
+ // Surface ffmpeg's own diagnostics (encoder error, etc.) in case stderr
693
+ // arrives after spawn.
694
+ ffmpeg.stderr?.on('data', () => { });
695
+ ffmpeg.on('error', () => { });
696
+ // 30 fps is CDP's screencast cap; everyNthFrame = round(30/fps).
697
+ const everyNthFrame = Math.max(1, Math.round(30 / fps));
698
+ await conn.cdp.send('Page.startScreencast', { format: 'jpeg', quality: 60, everyNthFrame }, sessionId);
699
+ const frameHandler = (params) => {
700
+ const p = params;
701
+ try {
702
+ ffmpeg.stdin?.write(Buffer.from(p.data, 'base64'));
703
+ }
704
+ catch {
705
+ // ffmpeg exited; ignore writes
706
+ }
707
+ // Must ack every frame or CDP stops sending.
708
+ conn.cdp.send('Page.screencastFrameAck', { sessionId: p.sessionId }, sessionId).catch(() => { });
709
+ };
710
+ conn.cdp.on('Page.screencastFrame', frameHandler);
711
+ const durationMs = durationSec * 1000;
712
+ const maxBytes = maxMb * 1024 * 1024;
713
+ const state = {
714
+ outputPath,
715
+ startedAt: Date.now(),
716
+ fps,
717
+ durationMs,
718
+ maxBytes,
719
+ ffmpeg,
720
+ sessionId,
721
+ conn,
722
+ frameHandler,
723
+ durationTimer: setTimeout(() => {
724
+ this.recordStop(taskId, 'duration-cap').catch(() => { });
725
+ }, durationMs),
726
+ sizeCheckInterval: setInterval(async () => {
727
+ try {
728
+ const st = await fs.promises.stat(outputPath);
729
+ if (st.size >= maxBytes) {
730
+ await this.recordStop(taskId, 'size-cap');
731
+ }
732
+ }
733
+ catch {
734
+ // File may not exist yet
735
+ }
736
+ }, 1000),
737
+ };
738
+ this.recordings.set(taskId, state);
739
+ return { path: outputPath, fps, durationCapSec: durationSec, maxMb };
740
+ }
741
+ async recordStop(taskId, reason = 'manual') {
742
+ const rec = this.recordings.get(taskId);
743
+ if (!rec) {
744
+ throw new Error(`Task "${taskId}" is not currently recording`);
745
+ }
746
+ if (rec.stopReason) {
747
+ // Already stopping (e.g. size-cap fired while user also called stop).
748
+ // Wait for in-flight finalize.
749
+ while (this.recordings.has(taskId)) {
750
+ await new Promise((r) => setTimeout(r, 25));
751
+ }
752
+ }
753
+ rec.stopReason = reason;
754
+ clearTimeout(rec.durationTimer);
755
+ clearInterval(rec.sizeCheckInterval);
756
+ rec.conn.cdp.off('Page.screencastFrame', rec.frameHandler);
757
+ try {
758
+ await rec.conn.cdp.send('Page.stopScreencast', {}, rec.sessionId);
759
+ }
760
+ catch {
761
+ // session may already be gone
762
+ }
763
+ // Close ffmpeg stdin so it flushes the output file cleanly.
764
+ await new Promise((resolve) => {
765
+ rec.ffmpeg.on('exit', () => resolve());
766
+ try {
767
+ rec.ffmpeg.stdin?.end();
768
+ }
769
+ catch {
770
+ resolve();
771
+ }
772
+ setTimeout(resolve, 5000); // Hard timeout if ffmpeg hangs
773
+ });
774
+ let bytes = 0;
775
+ try {
776
+ const st = await fs.promises.stat(rec.outputPath);
777
+ bytes = st.size;
778
+ }
779
+ catch {
780
+ // ffmpeg may have failed; file missing
781
+ }
782
+ const durationMs = Date.now() - rec.startedAt;
783
+ this.recordings.delete(taskId);
784
+ return { path: rec.outputPath, bytes, durationMs, reason };
785
+ }
786
+ async recordStatus(taskId) {
787
+ const rec = this.recordings.get(taskId);
788
+ if (!rec)
789
+ return { recording: false };
790
+ return { recording: true, path: rec.outputPath, elapsedMs: Date.now() - rec.startedAt };
483
791
  }
484
792
  refsCache = new Map();
485
793
  async refs(taskId, tabHint, opts = {}) {
@@ -868,6 +1176,46 @@ export class BrowserService {
868
1176
  }
869
1177
  throw new Error(`No response matching "${urlPattern}" within ${timeout}ms`);
870
1178
  }
1179
+ // ─── App Logs (source-side JSONL) ───────────────────────────────────────────
1180
+ async getAppLogs(taskId, opts) {
1181
+ const { task } = await this.findTask(taskId);
1182
+ const baseProfileName = task.profile.split('@')[0];
1183
+ const profile = await getProfile(baseProfileName);
1184
+ if (!profile?.logDir) {
1185
+ throw new Error(`Profile '${task.profile}' has no logDir set`);
1186
+ }
1187
+ const logDir = expandHome(profile.logDir);
1188
+ const sources = opts.source ? [opts.source] : ['rush-app', 'rush-cli'];
1189
+ const since = opts.since ? parseSinceUntil(opts.since) : null;
1190
+ const until = opts.until ? parseSinceUntil(opts.until) : null;
1191
+ const tailN = since ? 100_000 : (opts.lines ?? 200);
1192
+ const raws = await Promise.all(sources.map(async (src) => {
1193
+ const prefix = BrowserService.SOURCE_PREFIX[src];
1194
+ if (!prefix)
1195
+ return '';
1196
+ if (profile.logHost) {
1197
+ return execSSH(profile.logHost, `ls -1t ${logDir}/${prefix}*.jsonl 2>/dev/null | head -1 | xargs -r tail -n ${tailN}`);
1198
+ }
1199
+ return readNewestMatchingFile(logDir, prefix, tailN);
1200
+ }));
1201
+ const entries = raws
1202
+ .flatMap((r) => r.split('\n').filter(Boolean))
1203
+ .map((line) => {
1204
+ try {
1205
+ return JSON.parse(line);
1206
+ }
1207
+ catch {
1208
+ return { raw: line };
1209
+ }
1210
+ })
1211
+ .filter((e) => !opts.level || e.level === opts.level)
1212
+ .filter((e) => !opts.message || e.message === opts.message)
1213
+ .filter((e) => !opts.filter || JSON.stringify(e).includes(opts.filter))
1214
+ .filter((e) => !since || (e.timestamp && new Date(e.timestamp) >= since))
1215
+ .filter((e) => !until || (e.timestamp && new Date(e.timestamp) <= until))
1216
+ .sort((a, b) => new Date(a.timestamp ?? 0).getTime() - new Date(b.timestamp ?? 0).getTime());
1217
+ return since ? entries : entries.slice(-(opts.lines ?? 200));
1218
+ }
871
1219
  // ─── Wait Conditions ─────────────────────────────────────────────────────────
872
1220
  async wait(taskId, type, value, options = {}) {
873
1221
  const timeout = options.timeout ?? 30000;
@@ -969,8 +1317,37 @@ export class BrowserService {
969
1317
  return undefined;
970
1318
  }
971
1319
  async shutdown() {
1320
+ // Drain any in-flight recordings first so we don't orphan ffmpeg processes
1321
+ // or leak the duration/size-check timers when the daemon goes down.
1322
+ for (const [taskId, rec] of this.recordings) {
1323
+ clearTimeout(rec.durationTimer);
1324
+ clearInterval(rec.sizeCheckInterval);
1325
+ try {
1326
+ rec.conn.cdp.off('Page.screencastFrame', rec.frameHandler);
1327
+ }
1328
+ catch { /* socket may be gone */ }
1329
+ try {
1330
+ rec.ffmpeg.stdin?.end();
1331
+ }
1332
+ catch { /* already closed */ }
1333
+ // Give ffmpeg up to 1s to flush; then SIGKILL.
1334
+ const exited = await new Promise((resolve) => {
1335
+ let done = false;
1336
+ rec.ffmpeg.once('exit', () => { done = true; resolve(true); });
1337
+ setTimeout(() => { if (!done)
1338
+ resolve(false); }, 1000);
1339
+ });
1340
+ if (!exited) {
1341
+ try {
1342
+ rec.ffmpeg.kill('SIGKILL');
1343
+ }
1344
+ catch { /* already dead */ }
1345
+ }
1346
+ this.recordings.delete(taskId);
1347
+ }
972
1348
  for (const [, conn] of this.connections) {
973
1349
  conn.cdp.close();
1350
+ conn.cleanup?.();
974
1351
  }
975
1352
  this.connections.clear();
976
1353
  }
@@ -990,7 +1367,7 @@ export class BrowserService {
990
1367
  const forkName = `${profile.name}.${forkNum}`;
991
1368
  const port = allocatePort();
992
1369
  const chromeOpts = { ...profile.chrome, viewport: profile.viewport };
993
- const { pid, wsUrl } = await launchBrowser(forkName, profile.browser, port, chromeOpts, profile.secrets, profile.binary);
1370
+ const { pid, wsUrl } = await launchBrowser(forkName, profile.browser, port, chromeOpts, profile.secrets, profile.binary, profile.electron === true);
994
1371
  const cdp = new CDPClient();
995
1372
  await cdp.connect(wsUrl);
996
1373
  await this.enableDomains(cdp);
@@ -1007,39 +1384,50 @@ export class BrowserService {
1007
1384
  this.connections.set(forkName, connection);
1008
1385
  return { forkName, connection };
1009
1386
  }
1010
- async connectProfile(profile) {
1011
- const existingInfo = getRunningChromeInfo(profile.name);
1387
+ /**
1388
+ * Connect to a profile at a specific endpoint preset. The caller has
1389
+ * already resolved the endpoint and built the `effectiveProfile` with
1390
+ * the per-endpoint binary/targetFilter overrides applied; we just use it.
1391
+ *
1392
+ * `effectiveProfile.name` is the composite identifier (`<profile>@<endpoint>`)
1393
+ * so per-endpoint pid/port files don't collide when the same app runs
1394
+ * locally and remotely at the same time.
1395
+ */
1396
+ async connectProfile(effectiveProfile, target) {
1397
+ const existingInfo = getRunningChromeInfo(effectiveProfile.name);
1012
1398
  if (existingInfo) {
1013
- const { wsUrl, browser } = await discoverBrowserWsUrl(existingInfo.port);
1014
- verifyBrowserIdentity(browser, profile.browser, existingInfo.port);
1015
- const cdp = new CDPClient();
1016
- await cdp.connect(wsUrl);
1017
- await this.enableDomains(cdp);
1018
- const tasks = this.loadTaskState(profile.name);
1019
- return {
1020
- cdp,
1021
- port: existingInfo.port,
1022
- pid: existingInfo.pid,
1023
- electron: profile.electron,
1024
- targetFilter: profile.targetFilter,
1025
- tasks,
1026
- sessionCache: new Map(),
1027
- };
1028
- }
1029
- for (const endpoint of profile.endpoints) {
1030
1399
  try {
1031
- const conn = await this.connectEndpoint(profile, endpoint);
1032
- if (conn)
1033
- return conn;
1400
+ const { wsUrl, browser } = await discoverBrowserWsUrl(existingInfo.port);
1401
+ verifyBrowserIdentity(browser, effectiveProfile.browser, existingInfo.port);
1402
+ const cdp = new CDPClient();
1403
+ await cdp.connect(wsUrl);
1404
+ await this.enableDomains(cdp);
1405
+ const tasks = this.loadTaskState(effectiveProfile.name);
1406
+ return {
1407
+ cdp,
1408
+ port: existingInfo.port,
1409
+ pid: existingInfo.pid,
1410
+ electron: effectiveProfile.electron,
1411
+ targetFilter: effectiveProfile.targetFilter,
1412
+ tasks,
1413
+ sessionCache: new Map(),
1414
+ };
1034
1415
  }
1035
1416
  catch (err) {
1036
- if (err instanceof Error && err.message.startsWith('Browser identity mismatch')) {
1037
- throw err;
1038
- }
1039
- // Try next endpoint
1417
+ // pid file says a process is alive on that port, but nothing is
1418
+ // actually responding to CDP — most commonly because the user
1419
+ // changed the configured endpoint port after a previous launch,
1420
+ // or because the OS reused the pid for an unrelated process.
1421
+ // Wipe the stale runtime files and fall through to a fresh
1422
+ // connect against the profile's currently-configured endpoint.
1423
+ clearProfileRuntime(effectiveProfile.name);
1040
1424
  }
1041
1425
  }
1042
- throw new Error(`Could not connect to any endpoint for profile "${profile.name}"`);
1426
+ const conn = await this.connectEndpoint(effectiveProfile, target);
1427
+ if (!conn) {
1428
+ throw new Error(`Could not connect to endpoint ${target} for profile "${effectiveProfile.name}"`);
1429
+ }
1430
+ return conn;
1043
1431
  }
1044
1432
  async connectEndpoint(profile, endpoint) {
1045
1433
  const url = new URL(endpoint);
@@ -1067,6 +1455,7 @@ export class BrowserService {
1067
1455
  targetFilter: profile.targetFilter,
1068
1456
  tasks: new Map(),
1069
1457
  sessionCache: new Map(),
1458
+ cleanup: conn.cleanup,
1070
1459
  };
1071
1460
  }
1072
1461
  if (url.protocol === 'wss:' || url.protocol === 'ws:') {
@@ -1143,6 +1532,21 @@ export class BrowserService {
1143
1532
  conn.windowId = result.targetId;
1144
1533
  return result.targetId;
1145
1534
  }
1535
+ hasTaskNamed(name) {
1536
+ for (const conn of this.connections.values()) {
1537
+ if (conn.tasks.has(name))
1538
+ return true;
1539
+ }
1540
+ return false;
1541
+ }
1542
+ generateUniqueTaskName() {
1543
+ for (let attempt = 0; attempt < 8; attempt++) {
1544
+ const candidate = generateTaskName();
1545
+ if (!this.hasTaskNamed(candidate))
1546
+ return candidate;
1547
+ }
1548
+ throw new Error('Could not generate unique task name after 8 attempts');
1549
+ }
1146
1550
  async findTask(taskId, profileName) {
1147
1551
  if (profileName) {
1148
1552
  const conn = this.connections.get(profileName);