@web-auto/camo 0.1.21 → 0.1.23

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/README.md CHANGED
@@ -176,7 +176,7 @@ camo stop --id <instanceId>
176
176
  camo stop --alias <alias>
177
177
  camo stop idle
178
178
  camo stop all
179
- camo status [profileId]
179
+ camo status [profileId] # Show resolved per-profile session view
180
180
  camo shutdown # Shutdown browser-service (all sessions)
181
181
  ```
182
182
 
@@ -186,19 +186,53 @@ Use `--width/--height` to override and update the saved profile size.
186
186
  For headless sessions, default idle timeout is `30m` (auto-stop on inactivity). Use `--idle-timeout` (e.g. `45m`, `1800s`, `0`) to customize.
187
187
  Use `--devtools` to open browser developer tools in headed mode (cannot be combined with `--headless`).
188
188
  Use `--record` to auto-enable JSONL recording at startup; `--record-name`, `--record-output`, and `--record-overlay` customize file naming/output and floating toggle UI.
189
+ Set `CAMO_BRING_TO_FRONT_MODE=never` to keep protocol-level input and page lifecycle operations from forcing the browser window to front during headed runs.
190
+ `CAMO_SKIP_BRING_TO_FRONT=1` remains supported as a legacy alias.
189
191
 
190
192
  ### Lifecycle & Cleanup
191
193
 
192
194
  ```bash
193
- camo instances # List global camoufox instances (live + orphaned + idle state)
194
- camo sessions # List active browser sessions
195
- camo cleanup [profileId] # Cleanup session (release lock + stop)
195
+ camo instances # List resolved session view (live + registered + idle state)
196
+ camo sessions # List resolved session view for all profiles
197
+ camo cleanup [profileId] # Cleanup only one profile (remote stop + local registry/lock/watchdog)
196
198
  camo cleanup all # Cleanup all active sessions
197
199
  camo cleanup locks # Cleanup stale lock files
198
- camo force-stop [profileId] # Force stop session (for stuck sessions)
200
+ camo force-stop [profileId] # Force stop only one profile (no alias/id targeting)
199
201
  camo lock list # List active session locks
200
202
  ```
201
203
 
204
+ Session isolation rules:
205
+ - `profileId` is the lifecycle primary key across browser-service session, local registry, watchdog, and lock.
206
+ - `camo start/stop/cleanup/force-stop <profileId>` only target that exact profile and must not affect other profiles.
207
+ - `camo stop --id` and `camo stop --alias` are stop-only convenience selectors; `cleanup` and `force-stop` intentionally reject indirect targeting.
208
+ - `camo status`, `camo sessions`, and `camo instances` share the same resolved session view fields:
209
+ - `live`: browser-service currently has this profile session
210
+ - `registered`: local registry has metadata for this profile
211
+ - `orphaned`: registry exists but the service session is gone
212
+ - `needsRecovery`: registry still says active but browser-service no longer has that profile
213
+
214
+ Isolation examples:
215
+
216
+ ```bash
217
+ # Observe both profiles independently
218
+ camo sessions
219
+ camo status finger
220
+ camo status xhs-qa-1
221
+
222
+ # Stop only finger; xhs-qa-1 must remain live
223
+ camo stop finger
224
+ camo status finger
225
+ camo status xhs-qa-1
226
+
227
+ # cleanup / force-stop require direct profile targeting
228
+ camo cleanup finger
229
+ camo force-stop finger
230
+
231
+ # Invalid on purpose: indirect targeting is rejected
232
+ camo cleanup --alias shard1
233
+ camo force-stop --id inst_xxxxxxxx
234
+ ```
235
+
202
236
  ### Navigation
203
237
 
204
238
  ```bash
@@ -252,7 +286,7 @@ Recorder JSONL events include:
252
286
  camo new-page [profileId] [--url <url>]
253
287
  camo close-page [profileId] [index]
254
288
  camo switch-page [profileId] <index>
255
- camo list-pages [profileId]
289
+ camo list-pages [profileId] # Requires live=true for that profile
256
290
  ```
257
291
 
258
292
  ### Cookies
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@web-auto/camo",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "description": "Camoufox Browser CLI - Cross-platform browser automation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Version bumper for camo CLI
4
- * Increments patch version maintaining 4-digit format (0.1.0001 -> 0.1.0002)
3
+ * Version bumper for camo CLI.
4
+ * Increments standard semver patch version (0.1.21 -> 0.1.22).
5
5
  */
6
6
 
7
7
  import { readFileSync, writeFileSync } from 'fs';
@@ -15,18 +15,14 @@ const packageJsonPath = join(__dirname, '..', 'package.json');
15
15
  function bumpVersion() {
16
16
  const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
17
17
  const currentVersion = pkg.version;
18
-
19
- // Parse version: 0.1.0001 -> [0, 1, 1]
20
18
  const parts = currentVersion.split('.');
21
19
  const major = parseInt(parts[0], 10);
22
20
  const minor = parseInt(parts[1], 10);
23
21
  let patch = parseInt(parts[2], 10);
24
-
25
- // Increment patch
22
+
26
23
  patch += 1;
27
-
28
- // Format with 4 digits
29
- const newVersion = `${major}.${minor}.${patch.toString().padStart(4, '0')}`;
24
+
25
+ const newVersion = `${major}.${minor}.${patch}`;
30
26
 
31
27
  pkg.version = newVersion;
32
28
  writeFileSync(packageJsonPath, JSON.stringify(pkg, null, 2) + '\n');
@@ -7,7 +7,7 @@ import {
7
7
  getProfileWindowSize,
8
8
  setProfileWindowSize,
9
9
  } from '../utils/config.mjs';
10
- import { callAPI, ensureCamoufox, ensureBrowserService, getSessionByProfile, checkBrowserService } from '../utils/browser-service.mjs';
10
+ import { callAPI, ensureCamoufox, ensureBrowserService, getSessionByProfile, checkBrowserService, getResolvedSessions } from '../utils/browser-service.mjs';
11
11
  import { resolveProfileId, ensureUrlScheme, looksLikeUrlToken, getPositionals } from '../utils/args.mjs';
12
12
  import { ensureJsExecutionEnabled } from '../utils/js-policy.mjs';
13
13
  import { acquireLock, releaseLock, cleanupStaleLocks } from '../lifecycle/lock.mjs';
@@ -298,6 +298,15 @@ async function stopAndCleanupProfile(profileId, options = {}) {
298
298
  };
299
299
  }
300
300
 
301
+ async function loadResolvedSessions(serviceUp) {
302
+ if (!serviceUp) return [];
303
+ try {
304
+ return await getResolvedSessions();
305
+ } catch {
306
+ return [];
307
+ }
308
+ }
309
+
301
310
  async function probeViewportSize(profileId) {
302
311
  try {
303
312
  const payload = await callAPI('evaluate', {
@@ -726,23 +735,17 @@ export async function handleStopCommand(args) {
726
735
  const stopIdle = target === 'idle' || args.includes('--idle');
727
736
  const stopAll = target === 'all';
728
737
  const serviceUp = await checkBrowserService();
738
+ const resolvedSessions = await loadResolvedSessions(serviceUp);
729
739
 
730
740
  if (stopAll) {
731
- let liveSessions = [];
732
- if (serviceUp) {
733
- try {
734
- const status = await callAPI('getStatus', {});
735
- liveSessions = Array.isArray(status?.sessions) ? status.sessions : [];
736
- } catch {
737
- // Ignore and fallback to local registry.
741
+ const profileSet = new Set(resolvedSessions.map((item) => String(item?.profileId || '').trim()).filter(Boolean));
742
+ if (profileSet.size === 0) {
743
+ for (const session of listRegisteredSessions()) {
744
+ if (String(session?.status || '').trim() === 'closed') continue;
745
+ const profileId = String(session?.profileId || '').trim();
746
+ if (profileId) profileSet.add(profileId);
738
747
  }
739
748
  }
740
- const profileSet = new Set(liveSessions.map((item) => String(item?.profileId || '').trim()).filter(Boolean));
741
- for (const session of listRegisteredSessions()) {
742
- if (String(session?.status || '').trim() === 'closed') continue;
743
- const profileId = String(session?.profileId || '').trim();
744
- if (profileId) profileSet.add(profileId);
745
- }
746
749
 
747
750
  const results = [];
748
751
  for (const profileId of profileSet) {
@@ -763,15 +766,6 @@ export async function handleStopCommand(args) {
763
766
  if (stopIdle) {
764
767
  const now = Date.now();
765
768
  const registeredSessions = listRegisteredSessions();
766
- let liveSessions = [];
767
- if (serviceUp) {
768
- try {
769
- const status = await callAPI('getStatus', {});
770
- liveSessions = Array.isArray(status?.sessions) ? status.sessions : [];
771
- } catch {
772
- // Ignore and fallback to local registry.
773
- }
774
- }
775
769
  const regMap = new Map(
776
770
  registeredSessions
777
771
  .filter((item) => item && String(item?.status || '').trim() === 'active')
@@ -785,7 +779,7 @@ export async function handleStopCommand(args) {
785
779
  .map((item) => item.session.profileId),
786
780
  );
787
781
  let orphanLiveHeadlessCount = 0;
788
- for (const live of liveSessions) {
782
+ for (const live of resolvedSessions.filter((item) => item.live)) {
789
783
  const liveProfileId = String(live?.profileId || '').trim();
790
784
  if (!liveProfileId) continue;
791
785
  if (regMap.has(liveProfileId) || idleTargets.has(liveProfileId)) continue;
@@ -863,14 +857,15 @@ export async function handleStopCommand(args) {
863
857
 
864
858
  export async function handleStatusCommand(args) {
865
859
  await ensureBrowserService();
866
- const result = await callAPI('getStatus', {});
867
860
  const profileId = args[1];
868
861
  if (profileId && args[0] === 'status') {
869
- const session = result?.sessions?.find((s) => s.profileId === profileId) || null;
862
+ const sessions = await getResolvedSessions();
863
+ const session = sessions.find((item) => item.profileId === profileId) || null;
870
864
  console.log(JSON.stringify({ ok: true, session }, null, 2));
871
865
  return;
872
866
  }
873
- console.log(JSON.stringify(result, null, 2));
867
+ const sessions = await getResolvedSessions();
868
+ console.log(JSON.stringify({ ok: true, sessions, count: sessions.length }, null, 2));
874
869
  }
875
870
 
876
871
  export async function handleGotoCommand(args) {
@@ -974,14 +969,32 @@ export async function handleScrollCommand(args) {
974
969
 
975
970
  const target = await callAPI('evaluate', {
976
971
  profileId,
977
- script: buildScrollTargetScript({ selector, highlight }),
972
+ script: buildScrollTargetScript({ selector, highlight, requireVisibleContainer: Boolean(selector) }),
978
973
  }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
974
+ const scrollTarget = target?.result || null;
975
+ if (!scrollTarget?.ok || !scrollTarget?.center) {
976
+ throw new Error(scrollTarget?.error || 'visible scroll container not found');
977
+ }
979
978
  const deltaX = direction === 'left' ? -amount : direction === 'right' ? amount : 0;
980
979
  const deltaY = direction === 'up' ? -amount : direction === 'down' ? amount : 0;
981
- const result = await callAPI('mouse:wheel', { profileId, deltaX, deltaY }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
980
+ await callAPI('mouse:click', {
981
+ profileId,
982
+ x: scrollTarget.center.x,
983
+ y: scrollTarget.center.y,
984
+ button: 'left',
985
+ clicks: 1,
986
+ delay: 30,
987
+ }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
988
+ const result = await callAPI('mouse:wheel', {
989
+ profileId,
990
+ deltaX,
991
+ deltaY,
992
+ anchorX: scrollTarget.center.x,
993
+ anchorY: scrollTarget.center.y,
994
+ }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
982
995
  console.log(JSON.stringify({
983
996
  ...result,
984
- scrollTarget: target?.result || null,
997
+ scrollTarget,
985
998
  highlight,
986
999
  }, null, 2));
987
1000
  }
@@ -1183,6 +1196,11 @@ export async function handleListPagesCommand(args) {
1183
1196
  await ensureBrowserService();
1184
1197
  const profileId = resolveProfileId(args, 1, getDefaultProfile);
1185
1198
  if (!profileId) throw new Error('Usage: camo list-pages [profileId] (or set default profile first)');
1199
+ const sessions = await getResolvedSessions();
1200
+ const session = sessions.find((item) => item.profileId === profileId) || null;
1201
+ if (!session?.live) {
1202
+ throw new Error(`Profile session not live: ${profileId}. Start via "camo start ${profileId}" first.`);
1203
+ }
1186
1204
  const result = await callAPI('page:list', { profileId });
1187
1205
  console.log(JSON.stringify(result, null, 2));
1188
1206
  }
@@ -1223,33 +1241,9 @@ export async function handleShutdownCommand() {
1223
1241
 
1224
1242
  export async function handleSessionsCommand(args) {
1225
1243
  const serviceUp = await checkBrowserService();
1244
+ const merged = await loadResolvedSessions(serviceUp);
1226
1245
  const registeredSessions = listRegisteredSessions();
1227
-
1228
- let liveSessions = [];
1229
- if (serviceUp) {
1230
- try {
1231
- const status = await callAPI('getStatus', {});
1232
- liveSessions = Array.isArray(status?.sessions) ? status.sessions : [];
1233
- } catch {
1234
- // Service may have just become unavailable
1235
- }
1236
- }
1237
-
1238
- // Merge live and registered sessions
1239
- const liveProfileIds = new Set(liveSessions.map(s => s.profileId));
1240
- const merged = [...liveSessions];
1241
-
1242
- // Add registered sessions that are not in live sessions (need recovery)
1243
- for (const reg of registeredSessions) {
1244
- if (!liveProfileIds.has(reg.profileId) && reg.status === 'active') {
1245
- merged.push({
1246
- ...reg,
1247
- live: false,
1248
- needsRecovery: true,
1249
- });
1250
- }
1251
- }
1252
-
1246
+
1253
1247
  console.log(JSON.stringify({
1254
1248
  ok: true,
1255
1249
  serviceUp,
@@ -3,84 +3,26 @@
3
3
  * Lifecycle commands: cleanup, force-stop, lock management, session recovery
4
4
  */
5
5
  import { getDefaultProfile } from '../utils/config.mjs';
6
- import { callAPI, ensureBrowserService, checkBrowserService } from '../utils/browser-service.mjs';
6
+ import { callAPI, ensureBrowserService, checkBrowserService, getResolvedSessions } from '../utils/browser-service.mjs';
7
7
  import { resolveProfileId } from '../utils/args.mjs';
8
8
  import { acquireLock, getLockInfo, releaseLock, cleanupStaleLocks, listActiveLocks } from '../lifecycle/lock.mjs';
9
- import {
9
+ import {
10
10
  getSessionInfo, unregisterSession, markSessionClosed, cleanupStaleSessions,
11
11
  listRegisteredSessions, updateSession
12
12
  } from '../lifecycle/session-registry.mjs';
13
13
  import { stopAllSessionWatchdogs, stopSessionWatchdog } from '../lifecycle/session-watchdog.mjs';
14
14
 
15
- const DEFAULT_HEADLESS_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
16
-
17
- function computeIdleState(session, now = Date.now()) {
18
- const headless = session?.headless === true;
19
- const timeoutMs = headless
20
- ? (Number.isFinite(Number(session?.idleTimeoutMs)) ? Math.max(0, Number(session.idleTimeoutMs)) : DEFAULT_HEADLESS_IDLE_TIMEOUT_MS)
21
- : 0;
22
- const lastAt = Number(session?.lastActivityAt || session?.lastSeen || session?.startTime || now);
23
- const idleMs = Math.max(0, now - (Number.isFinite(lastAt) ? lastAt : now));
24
- const idle = headless && timeoutMs > 0 && idleMs >= timeoutMs;
25
- return { headless, timeoutMs, idleMs, idle };
26
- }
27
-
28
- function buildMergedSessionRows(liveSessions, registeredSessions) {
29
- const now = Date.now();
30
- const regMap = new Map(registeredSessions.map((item) => [item.profileId, item]));
31
- const rows = [];
32
- const liveMap = new Map(liveSessions.map((item) => [item.profileId, item]));
33
-
34
- for (const live of liveSessions) {
35
- const reg = regMap.get(live.profileId);
36
- const idle = computeIdleState(reg || {}, now);
37
- rows.push({
38
- profileId: live.profileId,
39
- sessionId: live.session_id || live.profileId,
40
- instanceId: reg?.instanceId || live.session_id || live.profileId,
41
- alias: reg?.alias || null,
42
- url: live.current_url,
43
- mode: live.mode,
44
- headless: idle.headless,
45
- idleTimeoutMs: idle.timeoutMs,
46
- idleMs: idle.idleMs,
47
- idle: idle.idle,
48
- live: true,
49
- registered: !!reg,
50
- registryStatus: reg?.status,
51
- lastSeen: reg?.lastSeen || null,
52
- lastActivityAt: reg?.lastActivityAt || null,
53
- });
15
+ function rejectIndirectSessionTargeting(commandName, args) {
16
+ if (args.includes('--id')) {
17
+ throw new Error(`Usage: camo ${commandName} [profileId] (direct profile only; --id is only supported by "camo stop")`);
54
18
  }
55
-
56
- for (const reg of registeredSessions) {
57
- if (liveMap.has(reg.profileId)) continue;
58
- if (reg.status === 'closed') continue;
59
- const idle = computeIdleState(reg, now);
60
- rows.push({
61
- profileId: reg.profileId,
62
- sessionId: reg.sessionId || reg.profileId,
63
- instanceId: reg.instanceId || reg.sessionId || reg.profileId,
64
- alias: reg.alias || null,
65
- url: reg.url || null,
66
- mode: reg.mode || null,
67
- headless: idle.headless,
68
- idleTimeoutMs: idle.timeoutMs,
69
- idleMs: idle.idleMs,
70
- idle: idle.idle,
71
- live: false,
72
- orphaned: true,
73
- needsRecovery: reg.status === 'active',
74
- registered: true,
75
- registryStatus: reg.status,
76
- lastSeen: reg.lastSeen || null,
77
- lastActivityAt: reg.lastActivityAt || null,
78
- });
19
+ if (args.includes('--alias')) {
20
+ throw new Error(`Usage: camo ${commandName} [profileId] (direct profile only; --alias is only supported by "camo stop")`);
79
21
  }
80
- return rows;
81
22
  }
82
23
 
83
24
  export async function handleCleanupCommand(args) {
25
+ rejectIndirectSessionTargeting('cleanup', args);
84
26
  const sub = args[1];
85
27
 
86
28
  if (sub === 'locks') {
@@ -158,6 +100,7 @@ export async function handleCleanupCommand(args) {
158
100
  }
159
101
 
160
102
  export async function handleForceStopCommand(args) {
103
+ rejectIndirectSessionTargeting('force-stop', args);
161
104
  await ensureBrowserService();
162
105
  const profileId = resolveProfileId(args, 1, getDefaultProfile);
163
106
  if (!profileId) throw new Error('Usage: camo force-stop [profileId]');
@@ -217,24 +160,45 @@ export async function handleUnlockCommand(args) {
217
160
  export async function handleSessionsCommand(args) {
218
161
  const serviceUp = await checkBrowserService();
219
162
  const registeredSessions = listRegisteredSessions();
220
-
221
- let liveSessions = [];
163
+ let merged = [];
222
164
  if (serviceUp) {
223
165
  try {
224
- const status = await callAPI('getStatus', {});
225
- liveSessions = Array.isArray(status?.sessions) ? status.sessions : [];
226
- } catch {}
166
+ merged = await getResolvedSessions();
167
+ } catch {
168
+ merged = [];
169
+ }
170
+ } else {
171
+ merged = registeredSessions
172
+ .filter((item) => String(item?.status || '').trim() !== 'closed')
173
+ .map((item) => ({
174
+ profileId: item.profileId,
175
+ sessionId: item.sessionId || item.profileId,
176
+ instanceId: item.instanceId || item.sessionId || item.profileId,
177
+ alias: item.alias || null,
178
+ url: item.url || null,
179
+ mode: item.mode || null,
180
+ ownerPid: Number(item?.ownerPid || item?.pid || 0) || null,
181
+ headless: item.headless === true,
182
+ idleTimeoutMs: Number(item.idleTimeoutMs || 0) || 0,
183
+ idleMs: 0,
184
+ idle: false,
185
+ live: false,
186
+ registered: true,
187
+ orphaned: true,
188
+ needsRecovery: String(item.status || '').trim() === 'active',
189
+ registryStatus: item.status || null,
190
+ lastSeen: item.lastSeen || null,
191
+ lastActivityAt: item.lastActivityAt || null,
192
+ }));
227
193
  }
228
-
229
- const merged = buildMergedSessionRows(liveSessions, registeredSessions);
230
-
194
+
231
195
  console.log(JSON.stringify({
232
196
  ok: true,
233
197
  serviceUp,
234
198
  sessions: merged,
235
199
  count: merged.length,
236
200
  registered: registeredSessions.length,
237
- live: liveSessions.length,
201
+ live: merged.filter((item) => item.live).length,
238
202
  orphaned: merged.filter(s => s.orphaned).length,
239
203
  }, null, 2));
240
204
  }
@@ -272,24 +236,24 @@ export async function handleRecoverCommand(args) {
272
236
 
273
237
  // Service is up - check if session is still there
274
238
  try {
275
- const status = await callAPI('getStatus', {});
276
- const existing = status?.sessions?.find(s => s.profileId === profileId);
277
-
239
+ const sessions = await getResolvedSessions();
240
+ const existing = sessions.find((item) => item.profileId === profileId && item.live);
241
+
278
242
  if (existing) {
279
243
  // Session is alive - update registry
280
244
  updateSession(profileId, {
281
- sessionId: existing.session_id || existing.profileId,
282
- url: existing.current_url,
245
+ sessionId: existing.sessionId || existing.profileId,
246
+ url: existing.url,
283
247
  status: 'active',
284
248
  recoveredAt: Date.now(),
285
249
  });
286
- acquireLock(profileId, { sessionId: existing.session_id || existing.profileId });
250
+ acquireLock(profileId, { sessionId: existing.sessionId || existing.profileId });
287
251
  console.log(JSON.stringify({
288
252
  ok: true,
289
253
  recovered: true,
290
254
  profileId,
291
- sessionId: existing.session_id || existing.profileId,
292
- url: existing.current_url,
255
+ sessionId: existing.sessionId || existing.profileId,
256
+ url: existing.url,
293
257
  message: 'Session reconnected successfully',
294
258
  }, null, 2));
295
259
  } else {
@@ -626,7 +626,24 @@ export async function executeOperation({ profileId, operation, context = {} }) {
626
626
  selector: anchorSelector,
627
627
  filterMode,
628
628
  });
629
- const result = await callAPI('mouse:wheel', { profileId: resolvedProfile, deltaX, deltaY });
629
+ if (!anchor?.ok || !anchor?.center) {
630
+ return asErrorPayload('OPERATION_FAILED', 'visible scroll container not found');
631
+ }
632
+ await callAPI('mouse:click', {
633
+ profileId: resolvedProfile,
634
+ x: anchor.center.x,
635
+ y: anchor.center.y,
636
+ button: 'left',
637
+ clicks: 1,
638
+ delay: 30,
639
+ });
640
+ const result = await callAPI('mouse:wheel', {
641
+ profileId: resolvedProfile,
642
+ deltaX,
643
+ deltaY,
644
+ anchorX: anchor.center.x,
645
+ anchorY: anchor.center.y,
646
+ });
630
647
  return {
631
648
  ok: true,
632
649
  code: 'OPERATION_DONE',
@@ -638,6 +655,7 @@ export async function executeOperation({ profileId, operation, context = {} }) {
638
655
  deltaY,
639
656
  filterMode,
640
657
  anchorSource: String(anchor?.source || 'document'),
658
+ anchorCenter: anchor?.center || null,
641
659
  modalLocked: anchor?.modalLocked === true,
642
660
  result,
643
661
  },
@@ -159,11 +159,13 @@ export function buildSelectorTypeScript({ selector, highlight, text }) {
159
159
  })()`;
160
160
  }
161
161
 
162
- export function buildScrollTargetScript({ selector, highlight }) {
162
+ export function buildScrollTargetScript({ selector, highlight, requireVisibleContainer = false }) {
163
163
  const selectorLiteral = JSON.stringify(String(selector || '').trim() || null);
164
164
  const highlightLiteral = asBoolLiteral(highlight);
165
+ const requireVisibleContainerLiteral = asBoolLiteral(requireVisibleContainer);
165
166
  return `(() => {
166
167
  const selector = ${selectorLiteral};
168
+ const requireVisibleContainer = ${requireVisibleContainerLiteral};
167
169
  const isVisible = (node) => {
168
170
  if (!(node instanceof Element)) return false;
169
171
  const rect = node.getBoundingClientRect?.();
@@ -183,11 +185,13 @@ export function buildScrollTargetScript({ selector, highlight }) {
183
185
  const style = window.getComputedStyle(node);
184
186
  const overflowY = String(style.overflowY || '');
185
187
  const overflowX = String(style.overflowX || '');
188
+ const scrollableSelectors = ['.comments-container', '.comment-list', '.comments-el', '.note-scroller'];
189
+ const selectorScrollable = scrollableSelectors.some((sel) => typeof node.matches === 'function' && node.matches(sel));
186
190
  const yScrollable = (overflowY.includes('auto') || overflowY.includes('scroll') || overflowY.includes('overlay'))
187
191
  && (node.scrollHeight - node.clientHeight > 2);
188
192
  const xScrollable = (overflowX.includes('auto') || overflowX.includes('scroll') || overflowX.includes('overlay'))
189
193
  && (node.scrollWidth - node.clientWidth > 2);
190
- return yScrollable || xScrollable;
194
+ return yScrollable || xScrollable || selectorScrollable;
191
195
  };
192
196
  const findScrollableAncestor = (node) => {
193
197
  let cursor = node instanceof Element ? node : null;
@@ -203,9 +207,12 @@ export function buildScrollTargetScript({ selector, highlight }) {
203
207
  if (selector) {
204
208
  const list = Array.from(document.querySelectorAll(selector));
205
209
  target = list.find((node) => isVisible(node) && isScrollable(node))
206
- || list.find((node) => isVisible(node))
210
+ || list.map((node) => findScrollableAncestor(node)).find((node) => isVisible(node))
207
211
  || null;
208
212
  if (target) source = 'selector';
213
+ if (!target && requireVisibleContainer) {
214
+ return { ok: false, error: 'visible_scroll_container_not_found', selector };
215
+ }
209
216
  }
210
217
  if (!target) {
211
218
  const active = document.activeElement instanceof Element ? document.activeElement : null;
@@ -250,9 +257,20 @@ export function buildScrollTargetScript({ selector, highlight }) {
250
257
  source,
251
258
  highlight: ${highlightLiteral},
252
259
  center: { x: centerX, y: centerY },
260
+ rect: {
261
+ left: Number(rect.left || 0),
262
+ top: Number(rect.top || 0),
263
+ width: Number(rect.width || 0),
264
+ height: Number(rect.height || 0)
265
+ },
253
266
  target: {
254
267
  tag: String(target.tagName || '').toLowerCase(),
255
- id: target.id || null
268
+ id: target.id || null,
269
+ className: typeof target.className === 'string' ? target.className : null,
270
+ scrollHeight: Number(target.scrollHeight || 0),
271
+ clientHeight: Number(target.clientHeight || 0),
272
+ scrollWidth: Number(target.scrollWidth || 0),
273
+ clientWidth: Number(target.clientWidth || 0)
256
274
  }
257
275
  };
258
276
  })()`;
@@ -0,0 +1,190 @@
1
+ import { buildSelectorCheck } from './utils.mjs';
2
+
3
+ function normalizeQuery(raw) {
4
+ const text = String(raw || '').trim();
5
+ if (!text) return { query: '', queryLower: '' };
6
+ return { query: text, queryLower: text.toLowerCase() };
7
+ }
8
+
9
+ function normalizeDirection(raw) {
10
+ const text = String(raw || 'down').trim().toLowerCase();
11
+ if (text === 'up' || text === 'down' || text === 'both') return text;
12
+ return 'down';
13
+ }
14
+
15
+ function normalizeLimit(raw) {
16
+ const num = Number(raw);
17
+ if (!Number.isFinite(num) || num <= 0) return 1;
18
+ return Math.max(1, Math.floor(num));
19
+ }
20
+
21
+ function normalizeRect(node) {
22
+ const rect = node?.rect && typeof node.rect === 'object' ? node.rect : null;
23
+ if (!rect) return null;
24
+ const left = Number(rect.left ?? rect.x ?? 0);
25
+ const top = Number(rect.top ?? rect.y ?? 0);
26
+ const width = Number(rect.width ?? 0);
27
+ const height = Number(rect.height ?? 0);
28
+ if (!Number.isFinite(left) || !Number.isFinite(top) || !Number.isFinite(width) || !Number.isFinite(height)) return null;
29
+ if (width <= 0 || height <= 0) return null;
30
+ return { left, top, width, height, right: left + width, bottom: top + height };
31
+ }
32
+
33
+ function computeCenter(rect) {
34
+ if (!rect) return null;
35
+ return {
36
+ x: Math.round(rect.left + rect.width / 2),
37
+ y: Math.round(rect.top + rect.height / 2),
38
+ };
39
+ }
40
+
41
+ function buildSearchText(node) {
42
+ if (!node || typeof node !== 'object') return '';
43
+ const parts = [];
44
+ const snippet = typeof node.textSnippet === 'string' ? node.textSnippet : '';
45
+ if (snippet) parts.push(snippet);
46
+ const attrs = node.attrs && typeof node.attrs === 'object' ? node.attrs : null;
47
+ if (attrs) {
48
+ const candidates = [
49
+ attrs['aria-label'],
50
+ attrs['aria-label'.toLowerCase()],
51
+ attrs.title,
52
+ attrs.alt,
53
+ attrs.placeholder,
54
+ ];
55
+ for (const item of candidates) {
56
+ const text = typeof item === 'string' ? item.trim() : '';
57
+ if (text) parts.push(text);
58
+ }
59
+ }
60
+ return parts.join(' ').replace(/\s+/g, ' ').trim();
61
+ }
62
+
63
+ function isPathWithin(path, parentPath) {
64
+ const child = String(path || '').trim();
65
+ const parent = String(parentPath || '').trim();
66
+ if (!child || !parent) return false;
67
+ return child === parent || child.startsWith(`${parent}/`);
68
+ }
69
+
70
+ function collectMatches(node, options, path = 'root', out = []) {
71
+ if (!node) return out;
72
+ const { queryLower, visibleOnly } = options;
73
+ const visible = node.visible === true;
74
+ if (visibleOnly && !visible) {
75
+ return out;
76
+ }
77
+ {
78
+ const searchText = buildSearchText(node);
79
+ if (searchText && searchText.toLowerCase().includes(queryLower)) {
80
+ out.push({ node, path, searchText });
81
+ }
82
+ }
83
+ if (Array.isArray(node.children)) {
84
+ for (let i = 0; i < node.children.length; i += 1) {
85
+ collectMatches(node.children[i], options, `${path}/${i}`, out);
86
+ }
87
+ }
88
+ return out;
89
+ }
90
+
91
+ function sortMatches(matches, direction) {
92
+ const sorted = [...matches].sort((a, b) => {
93
+ const ra = normalizeRect(a.targetNode);
94
+ const rb = normalizeRect(b.targetNode);
95
+ const ta = ra ? ra.top : Number.POSITIVE_INFINITY;
96
+ const tb = rb ? rb.top : Number.POSITIVE_INFINITY;
97
+ if (ta !== tb) return ta - tb;
98
+ const la = ra ? ra.left : Number.POSITIVE_INFINITY;
99
+ const lb = rb ? rb.left : Number.POSITIVE_INFINITY;
100
+ return la - lb;
101
+ });
102
+ if (direction === 'up') return sorted.reverse();
103
+ return sorted;
104
+ }
105
+
106
+ function applyStartAfter(matches, startAfterPath) {
107
+ if (!startAfterPath) return matches;
108
+ const idx = matches.findIndex((item) => item.targetPath === startAfterPath || item.matchPath === startAfterPath);
109
+ if (idx < 0) return matches;
110
+ return matches.slice(idx + 1);
111
+ }
112
+
113
+ export function searchSnapshot(snapshot, rawOptions = {}) {
114
+ const { query, queryLower } = normalizeQuery(rawOptions.query || rawOptions.keyword || rawOptions.text);
115
+ if (!query) {
116
+ return { ok: false, code: 'QUERY_REQUIRED', message: 'search requires query keyword', data: { query } };
117
+ }
118
+ const direction = normalizeDirection(rawOptions.direction || 'down');
119
+ const limit = normalizeLimit(rawOptions.limit ?? rawOptions.maxResults ?? 1);
120
+ const visibleOnly = rawOptions.visibleOnly !== false;
121
+ const containerSelector = String(rawOptions.containerSelector || rawOptions.selector || '').trim() || null;
122
+ const startAfterPath = String(rawOptions.startAfterPath || rawOptions.afterPath || '').trim() || null;
123
+
124
+ const containerNodes = containerSelector
125
+ ? buildSelectorCheck(snapshot, { css: containerSelector, visible: visibleOnly })
126
+ : [];
127
+ const containerPaths = containerNodes.map((node) => node.path).filter(Boolean);
128
+
129
+ const matches = collectMatches(snapshot, { queryLower, visibleOnly }, 'root', []);
130
+ const enriched = matches.map((match) => {
131
+ let containerNode = null;
132
+ let containerPath = null;
133
+ if (containerPaths.length > 0) {
134
+ for (const path of containerPaths) {
135
+ if (isPathWithin(match.path, path)) {
136
+ containerPath = path;
137
+ break;
138
+ }
139
+ }
140
+ if (containerPath) {
141
+ containerNode = containerNodes.find((node) => node.path === containerPath) || null;
142
+ }
143
+ }
144
+ const targetNode = containerNode || match.node;
145
+ const rect = normalizeRect(targetNode);
146
+ const center = computeCenter(rect);
147
+ return {
148
+ matchPath: match.path,
149
+ targetPath: containerPath || match.path,
150
+ targetNode,
151
+ matchNode: match.node,
152
+ containerPath,
153
+ rect,
154
+ center,
155
+ searchText: match.searchText,
156
+ };
157
+ });
158
+
159
+ const filtered = containerSelector
160
+ ? enriched.filter((item) => item.containerPath)
161
+ : enriched;
162
+ const ordered = sortMatches(filtered, direction);
163
+ const sliced = applyStartAfter(ordered, startAfterPath).slice(0, limit);
164
+ const results = sliced.map((item) => ({
165
+ matchPath: item.matchPath,
166
+ targetPath: item.targetPath,
167
+ containerPath: item.containerPath,
168
+ rect: item.rect,
169
+ center: item.center,
170
+ text: item.searchText,
171
+ }));
172
+ const nextCursor = results.length > 0 ? results[results.length - 1].targetPath : startAfterPath;
173
+
174
+ return {
175
+ ok: true,
176
+ code: 'SEARCH_OK',
177
+ message: 'search done',
178
+ data: {
179
+ query,
180
+ direction,
181
+ limit,
182
+ visibleOnly,
183
+ containerSelector,
184
+ totalMatches: filtered.length,
185
+ returned: results.length,
186
+ nextCursor,
187
+ results,
188
+ },
189
+ };
190
+ }
@@ -25,6 +25,11 @@ function normalizeAlias(value) {
25
25
  return text.slice(0, 64);
26
26
  }
27
27
 
28
+ function isAliasProtectedStatus(status) {
29
+ const normalized = String(status || '').trim().toLowerCase();
30
+ return normalized === 'active' || normalized === 'reconnecting';
31
+ }
32
+
28
33
  function normalizeTimeoutMs(value) {
29
34
  const ms = Number(value);
30
35
  if (!Number.isFinite(ms) || ms < 0) return null;
@@ -159,10 +164,21 @@ export function resolveSessionTarget(target) {
159
164
  const sessions = listRegisteredSessions();
160
165
  const byProfile = sessions.find((item) => String(item?.profileId || '').trim() === value);
161
166
  if (byProfile) return { profileId: byProfile.profileId, reason: 'profile', session: byProfile };
162
- const byInstanceId = sessions.find((item) => String(item?.instanceId || '').trim() === value);
163
- if (byInstanceId) return { profileId: byInstanceId.profileId, reason: 'instanceId', session: byInstanceId };
164
- const byAlias = sessions.find((item) => normalizeAlias(item?.alias) === normalizeAlias(value));
165
- if (byAlias) return { profileId: byAlias.profileId, reason: 'alias', session: byAlias };
167
+ const byInstanceId = sessions.filter((item) => String(item?.instanceId || '').trim() === value);
168
+ if (byInstanceId.length > 1) {
169
+ throw new Error(`Ambiguous instance id target: ${value}`);
170
+ }
171
+ if (byInstanceId.length === 1) {
172
+ return { profileId: byInstanceId[0].profileId, reason: 'instanceId', session: byInstanceId[0] };
173
+ }
174
+ const normalizedAlias = normalizeAlias(value);
175
+ const byAlias = sessions.filter((item) => normalizeAlias(item?.alias) === normalizedAlias && isAliasProtectedStatus(item?.status));
176
+ if (byAlias.length > 1) {
177
+ throw new Error(`Ambiguous alias target: ${value}`);
178
+ }
179
+ if (byAlias.length === 1) {
180
+ return { profileId: byAlias[0].profileId, reason: 'alias', session: byAlias[0] };
181
+ }
166
182
  return null;
167
183
  }
168
184
 
@@ -173,7 +189,7 @@ export function isSessionAliasTaken(alias, exceptProfileId = '') {
173
189
  return listRegisteredSessions().some((item) => {
174
190
  if (!item) return false;
175
191
  if (except && String(item.profileId || '').trim() === except) return false;
176
- if (String(item.status || '').trim() !== 'active') return false;
192
+ if (!isAliasProtectedStatus(item.status)) return false;
177
193
  return normalizeAlias(item.alias) === target;
178
194
  });
179
195
  }
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ import { listRegisteredSessions } from './session-registry.mjs';
3
+
4
+ const DEFAULT_HEADLESS_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
5
+
6
+ function computeIdleState(session, now = Date.now()) {
7
+ const headless = session?.headless === true;
8
+ const timeoutMs = headless
9
+ ? (Number.isFinite(Number(session?.idleTimeoutMs)) ? Math.max(0, Number(session.idleTimeoutMs)) : DEFAULT_HEADLESS_IDLE_TIMEOUT_MS)
10
+ : 0;
11
+ const lastAt = Number(session?.lastActivityAt || session?.lastSeen || session?.startTime || now);
12
+ const idleMs = Math.max(0, now - (Number.isFinite(lastAt) ? lastAt : now));
13
+ const idle = headless && timeoutMs > 0 && idleMs >= timeoutMs;
14
+ return { headless, timeoutMs, idleMs, idle };
15
+ }
16
+
17
+ function normalizeLiveSession(session) {
18
+ if (!session) return null;
19
+ const profileId = String(session.profileId || session.session_id || '').trim();
20
+ if (!profileId) return null;
21
+ return {
22
+ profileId,
23
+ sessionId: session.session_id || session.sessionId || profileId,
24
+ url: session.current_url || session.url || null,
25
+ mode: session.mode || null,
26
+ ownerPid: Number(session.owner_pid || session.ownerPid || 0) || null,
27
+ live: true,
28
+ raw: session,
29
+ };
30
+ }
31
+
32
+ export function buildResolvedSessionView(liveSessions = [], registeredSessions = listRegisteredSessions()) {
33
+ const now = Date.now();
34
+ const regMap = new Map(registeredSessions.map((item) => [String(item?.profileId || '').trim(), item]).filter(([key]) => key));
35
+ const liveMap = new Map(liveSessions.map(normalizeLiveSession).filter(Boolean).map((item) => [item.profileId, item]));
36
+ const profileIds = new Set([...liveMap.keys(), ...regMap.keys()]);
37
+ const rows = [];
38
+
39
+ for (const profileId of profileIds) {
40
+ const live = liveMap.get(profileId) || null;
41
+ const reg = regMap.get(profileId) || null;
42
+ if (!live && reg && String(reg.status || '').trim() === 'closed') continue;
43
+ const idle = computeIdleState(reg || live || {}, now);
44
+ const registryStatus = reg?.status || null;
45
+ const row = {
46
+ profileId,
47
+ sessionId: live?.sessionId || reg?.sessionId || reg?.instanceId || profileId,
48
+ instanceId: reg?.instanceId || live?.sessionId || profileId,
49
+ alias: reg?.alias || null,
50
+ url: live?.url || reg?.url || null,
51
+ mode: live?.mode || reg?.mode || null,
52
+ ownerPid: live?.ownerPid || Number(reg?.ownerPid || reg?.pid || 0) || null,
53
+ headless: idle.headless,
54
+ idleTimeoutMs: idle.timeoutMs,
55
+ idleMs: idle.idleMs,
56
+ idle: idle.idle,
57
+ live: Boolean(live),
58
+ registered: Boolean(reg),
59
+ orphaned: Boolean(reg) && !live,
60
+ needsRecovery: Boolean(reg) && !live && registryStatus === 'active',
61
+ registryStatus,
62
+ lastSeen: reg?.lastSeen || null,
63
+ lastActivityAt: reg?.lastActivityAt || null,
64
+ };
65
+ rows.push(row);
66
+ }
67
+
68
+ return rows.sort((a, b) => String(a.profileId).localeCompare(String(b.profileId)));
69
+ }
70
+
71
+ export function resolveSessionViewByProfile(profileId, liveSessions = [], registeredSessions = listRegisteredSessions()) {
72
+ const id = String(profileId || '').trim();
73
+ if (!id) return null;
74
+ return buildResolvedSessionView(liveSessions, registeredSessions).find((item) => item.profileId === id) || null;
75
+ }
76
+
@@ -582,8 +582,14 @@ async function handleCommand(payload, manager, wsServer, options = {}) {
582
582
  const session = manager.getSession(profileId);
583
583
  if (!session)
584
584
  throw new Error(`session for profile ${profileId} not started`);
585
- const { deltaY, deltaX } = args;
586
- await session.mouseWheel({ deltaY: Number(deltaY) || 0, deltaX: Number(deltaX) || 0 });
585
+ const { deltaY, deltaX, anchorX, anchorY } = args;
586
+ await session.mouseWheel({
587
+ deltaY: Number(deltaY) || 0,
588
+ deltaX: Number(deltaX) || 0,
589
+ ...(Number.isFinite(Number(anchorX)) && Number.isFinite(Number(anchorY))
590
+ ? { anchorX: Number(anchorX), anchorY: Number(anchorY) }
591
+ : {}),
592
+ });
587
593
  return { ok: true, body: { ok: true } };
588
594
  }
589
595
  case 'keyboard:type': {
@@ -196,10 +196,38 @@ test('ensureInputReady brings page to front even when document reports focus', a
196
196
  isClosed: () => false,
197
197
  };
198
198
  const session = new BrowserSession({ profileId: `test-input-ready-${Date.now()}` });
199
- await session.ensureInputReady(page);
199
+ await session.inputPipeline.ensureInputReady(page);
200
200
  assert.equal(page.bringToFrontCount, 1);
201
201
  assert.equal(page.waitCount, 1);
202
202
  });
203
+ test('ensureInputReady skips bringToFront when CAMO_BRING_TO_FRONT_MODE=never', async () => {
204
+ const restoreSkip = setEnv('CAMO_BRING_TO_FRONT_MODE', 'never');
205
+ try {
206
+ const page = {
207
+ bringToFrontCount: 0,
208
+ waitCount: 0,
209
+ bringToFront: async function bringToFront() {
210
+ this.bringToFrontCount += 1;
211
+ },
212
+ waitForTimeout: async function waitForTimeout() {
213
+ this.waitCount += 1;
214
+ },
215
+ evaluate: async () => ({
216
+ hasFocus: false,
217
+ hidden: false,
218
+ visibilityState: 'visible',
219
+ }),
220
+ isClosed: () => false,
221
+ };
222
+ const session = new BrowserSession({ profileId: `test-input-ready-skip-${Date.now()}` });
223
+ await session.inputPipeline.ensureInputReady(page);
224
+ assert.equal(page.bringToFrontCount, 0);
225
+ assert.equal(page.waitCount, 1);
226
+ }
227
+ finally {
228
+ restoreSkip();
229
+ }
230
+ });
203
231
  test('mouseWheel retries with refreshed active page after timeout', async () => {
204
232
  const restoreTimeout = setEnv('CAMO_INPUT_ACTION_TIMEOUT_MS', '80');
205
233
  const restoreAttempts = setEnv('CAMO_INPUT_ACTION_MAX_ATTEMPTS', '2');
@@ -210,17 +238,21 @@ test('mouseWheel retries with refreshed active page after timeout', async () =>
210
238
  const calls = [];
211
239
  const page1 = {
212
240
  isClosed: () => false,
241
+ viewportSize: () => ({ width: 1280, height: 720 }),
213
242
  bringToFront: async () => { },
214
243
  waitForTimeout: async () => { },
215
244
  mouse: {
245
+ move: async () => { },
216
246
  wheel: async () => new Promise(() => { }),
217
247
  },
218
248
  };
219
249
  const page2 = {
220
250
  isClosed: () => false,
251
+ viewportSize: () => ({ width: 1280, height: 720 }),
221
252
  bringToFront: async () => { },
222
253
  waitForTimeout: async () => { },
223
254
  mouse: {
255
+ move: async () => { },
224
256
  wheel: async () => {
225
257
  calls.push('wheel_ok');
226
258
  },
@@ -255,9 +287,11 @@ test('mouseWheel falls back to keyboard paging when wheel keeps timing out', asy
255
287
  const calls = [];
256
288
  const page = {
257
289
  isClosed: () => false,
290
+ viewportSize: () => ({ width: 1280, height: 720 }),
258
291
  bringToFront: async () => { },
259
292
  waitForTimeout: async () => { },
260
293
  mouse: {
294
+ move: async () => { },
261
295
  wheel: async () => new Promise(() => { }),
262
296
  },
263
297
  keyboard: {
@@ -319,4 +353,4 @@ test('mouseWheel uses keyboard mode directly when CAMO_SCROLL_INPUT_MODE=keyboar
319
353
  restoreMode();
320
354
  }
321
355
  });
322
- //# sourceMappingURL=BrowserSession.input.test.js.map
356
+ //# sourceMappingURL=BrowserSession.input.test.js.map
@@ -66,9 +66,11 @@ export class BrowserSessionInputOps {
66
66
  const page = await this.ensurePrimaryPage();
67
67
  await this.withInputActionLock(async () => {
68
68
  await this.runInputAction(page, 'input:ready', (activePage) => this.ensureInputReady(activePage));
69
- const { deltaX = 0, deltaY } = opts;
69
+ const { deltaX = 0, deltaY, anchorX, anchorY } = opts;
70
70
  const normalizedDeltaX = Number(deltaX) || 0;
71
71
  const normalizedDeltaY = Number(deltaY) || 0;
72
+ const normalizedAnchorX = Number(anchorX);
73
+ const normalizedAnchorY = Number(anchorY);
72
74
  if (normalizedDeltaY === 0 && normalizedDeltaX === 0)
73
75
  return;
74
76
  const keyboardKey = normalizedDeltaY > 0 ? 'PageDown' : 'PageUp';
@@ -88,8 +90,12 @@ export class BrowserSessionInputOps {
88
90
  try {
89
91
  await this.runInputAction(page, 'mouse:wheel', async (activePage) => {
90
92
  const viewport = activePage.viewportSize();
91
- const moveX = Math.max(1, Math.floor(((viewport?.width || 1280) * 0.5)));
92
- const moveY = Math.max(1, Math.floor(((viewport?.height || 720) * 0.5)));
93
+ const moveX = Number.isFinite(normalizedAnchorX)
94
+ ? Math.max(1, Math.min(Math.max(1, Number(viewport?.width || 1280) - 1), Math.round(normalizedAnchorX)))
95
+ : Math.max(1, Math.floor(((viewport?.width || 1280) * 0.5)));
96
+ const moveY = Number.isFinite(normalizedAnchorY)
97
+ ? Math.max(1, Math.min(Math.max(1, Number(viewport?.height || 720) - 1), Math.round(normalizedAnchorY)))
98
+ : Math.max(1, Math.floor(((viewport?.height || 720) * 0.5)));
93
99
  await activePage.mouse.move(moveX, moveY, { steps: 1 }).catch(() => { });
94
100
  await activePage.mouse.wheel(normalizedDeltaX, normalizedDeltaY);
95
101
  });
@@ -124,4 +130,4 @@ export class BrowserSessionInputOps {
124
130
  });
125
131
  }
126
132
  }
127
- //# sourceMappingURL=input-ops.js.map
133
+ //# sourceMappingURL=input-ops.js.map
@@ -1,4 +1,4 @@
1
- import { resolveInputActionMaxAttempts, resolveInputActionTimeoutMs, resolveInputRecoveryBringToFrontTimeoutMs, resolveInputRecoveryDelayMs, resolveInputReadySettleMs } from './utils.js';
1
+ import { resolveInputActionMaxAttempts, resolveInputActionTimeoutMs, resolveInputRecoveryBringToFrontTimeoutMs, resolveInputRecoveryDelayMs, resolveInputReadySettleMs, shouldSkipBringToFront } from './utils.js';
2
2
  import { ensurePageRuntime } from '../pageRuntime.js';
3
3
  export class BrowserInputPipeline {
4
4
  ensurePrimaryPage;
@@ -11,6 +11,13 @@ export class BrowserInputPipeline {
11
11
  async ensureInputReady(page) {
12
12
  if (this.isHeadless())
13
13
  return;
14
+ if (shouldSkipBringToFront()) {
15
+ const settleMs = resolveInputReadySettleMs();
16
+ if (settleMs > 0) {
17
+ await page.waitForTimeout(settleMs).catch(() => { });
18
+ }
19
+ return;
20
+ }
14
21
  const bringToFrontTimeoutMs = resolveInputRecoveryBringToFrontTimeoutMs();
15
22
  let bringToFrontTimer = null;
16
23
  try {
@@ -67,6 +74,7 @@ export class BrowserInputPipeline {
67
74
  }
68
75
  async recoverInputPipeline(page) {
69
76
  const activePage = await this.resolveInputPage(page).catch(() => page);
77
+ if (!shouldSkipBringToFront()) {
70
78
  const bringToFrontTimeoutMs = resolveInputRecoveryBringToFrontTimeoutMs();
71
79
  let bringToFrontTimer = null;
72
80
  try {
@@ -86,6 +94,7 @@ export class BrowserInputPipeline {
86
94
  if (bringToFrontTimer)
87
95
  clearTimeout(bringToFrontTimer);
88
96
  }
97
+ }
89
98
  const delayMs = resolveInputRecoveryDelayMs();
90
99
  if (delayMs > 0) {
91
100
  try {
@@ -130,4 +139,4 @@ export class BrowserInputPipeline {
130
139
  }
131
140
  }
132
141
  }
133
- //# sourceMappingURL=input-pipeline.js.map
142
+ //# sourceMappingURL=input-pipeline.js.map
@@ -1,6 +1,6 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { ensurePageRuntime } from '../pageRuntime.js';
3
- import { resolveNavigationWaitUntil, normalizeUrl } from './utils.js';
3
+ import { resolveNavigationWaitUntil, normalizeUrl, shouldSkipBringToFront } from './utils.js';
4
4
  export class BrowserSessionPageManagement {
5
5
  deps;
6
6
  constructor(deps) {
@@ -74,7 +74,9 @@ export class BrowserSessionPageManagement {
74
74
  const opener = this.deps.getActivePage() || ctx.pages()[0];
75
75
  if (!opener)
76
76
  throw new Error('no_opener_page');
77
- await opener.bringToFront().catch(() => null);
77
+ if (!shouldSkipBringToFront()) {
78
+ await opener.bringToFront().catch(() => null);
79
+ }
78
80
  const before = ctx.pages().filter((p) => !p.isClosed()).length;
79
81
  for (let attempt = 1; attempt <= 3; attempt += 1) {
80
82
  const waitPage = ctx.waitForEvent('page', { timeout: 8000 }).catch(() => null);
@@ -136,11 +138,13 @@ export class BrowserSessionPageManagement {
136
138
  catch {
137
139
  /* ignore */
138
140
  }
139
- try {
140
- await page.bringToFront();
141
- }
142
- catch {
143
- /* ignore */
141
+ if (!shouldSkipBringToFront()) {
142
+ try {
143
+ await page.bringToFront();
144
+ }
145
+ catch {
146
+ /* ignore */
147
+ }
144
148
  }
145
149
  if (url) {
146
150
  await page.goto(url, { waitUntil: resolveNavigationWaitUntil() });
@@ -165,11 +169,13 @@ export class BrowserSessionPageManagement {
165
169
  catch {
166
170
  /* ignore */
167
171
  }
168
- try {
169
- await page.bringToFront();
170
- }
171
- catch {
172
- /* ignore */
172
+ if (!shouldSkipBringToFront()) {
173
+ try {
174
+ await page.bringToFront();
175
+ }
176
+ catch {
177
+ /* ignore */
178
+ }
173
179
  }
174
180
  await ensurePageRuntime(page, true).catch(() => { });
175
181
  this.deps.recordLastKnownUrl(page.url());
@@ -194,11 +200,13 @@ export class BrowserSessionPageManagement {
194
200
  if (nextIndex >= 0) {
195
201
  const nextPage = remaining[nextIndex];
196
202
  this.deps.setActivePage(nextPage);
197
- try {
198
- await nextPage.bringToFront();
199
- }
200
- catch {
201
- /* ignore */
203
+ if (!shouldSkipBringToFront()) {
204
+ try {
205
+ await nextPage.bringToFront();
206
+ }
207
+ catch {
208
+ /* ignore */
209
+ }
202
210
  }
203
211
  await ensurePageRuntime(nextPage, true).catch(() => { });
204
212
  this.deps.recordLastKnownUrl(nextPage.url());
@@ -209,4 +217,4 @@ export class BrowserSessionPageManagement {
209
217
  return { closedIndex, activeIndex: nextIndex, total: remaining.length };
210
218
  }
211
219
  }
212
- //# sourceMappingURL=page-management.js.map
220
+ //# sourceMappingURL=page-management.js.map
@@ -28,6 +28,20 @@ export function resolveInputReadySettleMs() {
28
28
  const raw = Number(process.env.CAMO_INPUT_READY_SETTLE_MS ?? 80);
29
29
  return Math.max(0, Number.isFinite(raw) ? Math.floor(raw) : 80);
30
30
  }
31
+ export function resolveBringToFrontMode() {
32
+ const mode = String(process.env.CAMO_BRING_TO_FRONT_MODE ?? '').trim().toLowerCase();
33
+ if (mode === 'never' || mode === 'off' || mode === 'disabled')
34
+ return 'never';
35
+ if (mode === 'always' || mode === 'on' || mode === 'auto')
36
+ return 'auto';
37
+ const legacy = String(process.env.CAMO_SKIP_BRING_TO_FRONT ?? '').trim().toLowerCase();
38
+ if (legacy === '1' || legacy === 'true' || legacy === 'yes' || legacy === 'on')
39
+ return 'never';
40
+ return 'auto';
41
+ }
42
+ export function shouldSkipBringToFront() {
43
+ return resolveBringToFrontMode() === 'never';
44
+ }
31
45
  export function isTimeoutLikeError(error) {
32
46
  const message = String(error?.message || error || '').toLowerCase();
33
47
  return message.includes('timed out') || message.includes('timeout');
@@ -44,6 +58,12 @@ export function normalizeUrl(raw) {
44
58
  export async function ensureInputReadyOnPage(page, headless, bringToFrontTimeoutMs, settleMs) {
45
59
  if (headless)
46
60
  return;
61
+ if (shouldSkipBringToFront()) {
62
+ if (settleMs > 0) {
63
+ await page.waitForTimeout(settleMs).catch(() => { });
64
+ }
65
+ return;
66
+ }
47
67
  let bringToFrontTimer = null;
48
68
  try {
49
69
  await Promise.race([
@@ -66,4 +86,4 @@ export async function ensureInputReadyOnPage(page, headless, bringToFrontTimeout
66
86
  await page.waitForTimeout(settleMs).catch(() => { });
67
87
  }
68
88
  }
69
- //# sourceMappingURL=utils.js.map
89
+ //# sourceMappingURL=utils.js.map
@@ -7,6 +7,7 @@ import { createRequire } from 'node:module';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { BROWSER_SERVICE_URL, loadConfig, setRepoRoot } from './config.mjs';
9
9
  import { touchSessionActivity } from '../lifecycle/session-registry.mjs';
10
+ import { buildResolvedSessionView, resolveSessionViewByProfile } from '../lifecycle/session-view.mjs';
10
11
 
11
12
  const require = createRequire(import.meta.url);
12
13
  const DEFAULT_API_TIMEOUT_MS = 90000;
@@ -151,16 +152,21 @@ export async function callAPI(action, payload = {}, options = {}) {
151
152
 
152
153
  export async function getSessionByProfile(profileId) {
153
154
  const status = await callAPI('getStatus', {});
154
- const activeSession = status?.sessions?.find((s) => s.profileId === profileId) || null;
155
- if (activeSession) {
156
- return activeSession;
157
- }
158
155
  if (!profileId) {
159
156
  return null;
160
157
  }
158
+ const liveSessions = Array.isArray(status?.sessions) ? status.sessions : [];
159
+ const resolved = resolveSessionViewByProfile(profileId, liveSessions);
160
+ if (resolved?.live) {
161
+ const activeSession = liveSessions.find((session) => String(session?.profileId || '').trim() === resolved.profileId) || null;
162
+ if (activeSession) return activeSession;
163
+ }
164
+ if (!resolved?.live) {
165
+ return null;
166
+ }
161
167
 
162
- // Some browser-service builds do not populate getStatus.sessions reliably.
163
- // Fallback to page:list so runtime can still attach to an active profile tab set.
168
+ // Some browser-service builds do not populate current_url reliably.
169
+ // Fallback to page:list only to enrich an already-live profile.
164
170
  try {
165
171
  const pagePayload = await callAPI('page:list', { profileId });
166
172
  const pages = Array.isArray(pagePayload?.pages)
@@ -185,6 +191,12 @@ export async function getSessionByProfile(profileId) {
185
191
  }
186
192
  }
187
193
 
194
+ export async function getResolvedSessions() {
195
+ const status = await callAPI('getStatus', {});
196
+ const liveSessions = Array.isArray(status?.sessions) ? status.sessions : [];
197
+ return buildResolvedSessionView(liveSessions);
198
+ }
199
+
188
200
  function buildDomSnapshotScript(maxDepth, maxChildren) {
189
201
  return `(() => {
190
202
  const MAX_DEPTH = ${maxDepth};
@@ -32,16 +32,16 @@ BROWSER CONTROL:
32
32
  stop --alias <alias> Stop by alias
33
33
  stop idle Stop all idle sessions
34
34
  stop all Stop all sessions
35
- status [profileId]
35
+ status [profileId] Show resolved per-profile session view
36
36
  list Alias of status
37
37
 
38
38
  LIFECYCLE & CLEANUP:
39
- instances List global camoufox instances (live + registered + idle state)
40
- sessions List active browser sessions
41
- cleanup [profileId] Cleanup session (release lock + stop)
39
+ instances List resolved session view (live + registered + idle state)
40
+ sessions List resolved session view for all profiles
41
+ cleanup [profileId] Cleanup only one profile (remote stop + local registry/lock/watchdog)
42
42
  cleanup all Cleanup all active sessions
43
43
  cleanup locks Cleanup stale lock files
44
- force-stop [profileId] Force stop session (for stuck sessions)
44
+ force-stop [profileId] Force stop only one profile session (no alias/id targeting)
45
45
  lock list List active session locks
46
46
  lock [profileId] Show lock info for profile
47
47
  unlock [profileId] Release lock for profile
@@ -63,7 +63,7 @@ PAGES:
63
63
  new-page [profileId] [--url <url>]
64
64
  close-page [profileId] [index]
65
65
  switch-page [profileId] <index>
66
- list-pages [profileId]
66
+ list-pages [profileId] List pages only when that profile is live
67
67
 
68
68
  DEVTOOLS:
69
69
  devtools logs [profileId] [--limit 120] [--since <unix_ms>] [--levels error,warn] [--clear]
@@ -180,8 +180,31 @@ ENV:
180
180
  CAMO_ROOT Legacy data root (auto-appends .camo if needed)
181
181
  CAMO_WS_URL Optional ws://host:port override
182
182
  CAMO_WS_HOST / CAMO_WS_PORT WS host/port for browser-service
183
+ CAMO_BRING_TO_FRONT_MODE Bring-to-front policy: auto (default) | never
184
+ CAMO_SKIP_BRING_TO_FRONT Legacy alias for CAMO_BRING_TO_FRONT_MODE=never
183
185
  CAMO_PROGRESS_EVENTS_FILE Optional path override for progress jsonl
184
186
  CAMO_PROGRESS_WS_HOST / CAMO_PROGRESS_WS_PORT Progress daemon host/port (defaults: 127.0.0.1:7788)
187
+
188
+ SESSION ISOLATION:
189
+ - profile is the lifecycle primary key. browser-service, lock, watchdog, registry all bind to the same profileId.
190
+ - camo start/stop/cleanup/force-stop <profileId> only targets that exact profile and must not affect other profiles.
191
+ - stop --id / stop --alias are convenience selectors for stop only; cleanup/force-stop never accept alias or instance id.
192
+ - status/sessions/instances all read the same resolved session view with these fields:
193
+ live: browser-service currently has this profile session
194
+ registered: local registry has metadata for this profile
195
+ orphaned: registry exists but service session is gone
196
+ needsRecovery: registry says active but service no longer has that profile
197
+ - list-pages requires live=true for that profile; otherwise camo will fail fast instead of probing other profiles.
198
+
199
+ ISOLATION EXAMPLES:
200
+ camo sessions
201
+ camo status finger
202
+ camo stop finger
203
+ camo status xhs-qa-1
204
+ camo cleanup finger
205
+ camo force-stop finger
206
+ invalid: camo cleanup --alias shard1
207
+ invalid: camo force-stop --id inst_xxxxxxxx
185
208
  `);
186
209
  }
187
210