@web-auto/camo 0.1.22 → 0.1.24

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
 
@@ -192,15 +192,47 @@ Set `CAMO_BRING_TO_FRONT_MODE=never` to keep protocol-level input and page lifec
192
192
  ### Lifecycle & Cleanup
193
193
 
194
194
  ```bash
195
- camo instances # List global camoufox instances (live + orphaned + idle state)
196
- camo sessions # List active browser sessions
197
- 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)
198
198
  camo cleanup all # Cleanup all active sessions
199
199
  camo cleanup locks # Cleanup stale lock files
200
- camo force-stop [profileId] # Force stop session (for stuck sessions)
200
+ camo force-stop [profileId] # Force stop only one profile (no alias/id targeting)
201
201
  camo lock list # List active session locks
202
202
  ```
203
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
+
204
236
  ### Navigation
205
237
 
206
238
  ```bash
@@ -254,7 +286,7 @@ Recorder JSONL events include:
254
286
  camo new-page [profileId] [--url <url>]
255
287
  camo close-page [profileId] [index]
256
288
  camo switch-page [profileId] <index>
257
- camo list-pages [profileId]
289
+ camo list-pages [profileId] # Requires live=true for that profile
258
290
  ```
259
291
 
260
292
  ### Cookies
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@web-auto/camo",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "description": "Camoufox Browser CLI - Cross-platform browser automation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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) {
@@ -1201,6 +1196,11 @@ export async function handleListPagesCommand(args) {
1201
1196
  await ensureBrowserService();
1202
1197
  const profileId = resolveProfileId(args, 1, getDefaultProfile);
1203
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
+ }
1204
1204
  const result = await callAPI('page:list', { profileId });
1205
1205
  console.log(JSON.stringify(result, null, 2));
1206
1206
  }
@@ -1241,33 +1241,9 @@ export async function handleShutdownCommand() {
1241
1241
 
1242
1242
  export async function handleSessionsCommand(args) {
1243
1243
  const serviceUp = await checkBrowserService();
1244
+ const merged = await loadResolvedSessions(serviceUp);
1244
1245
  const registeredSessions = listRegisteredSessions();
1245
-
1246
- let liveSessions = [];
1247
- if (serviceUp) {
1248
- try {
1249
- const status = await callAPI('getStatus', {});
1250
- liveSessions = Array.isArray(status?.sessions) ? status.sessions : [];
1251
- } catch {
1252
- // Service may have just become unavailable
1253
- }
1254
- }
1255
-
1256
- // Merge live and registered sessions
1257
- const liveProfileIds = new Set(liveSessions.map(s => s.profileId));
1258
- const merged = [...liveSessions];
1259
-
1260
- // Add registered sessions that are not in live sessions (need recovery)
1261
- for (const reg of registeredSessions) {
1262
- if (!liveProfileIds.has(reg.profileId) && reg.status === 'active') {
1263
- merged.push({
1264
- ...reg,
1265
- live: false,
1266
- needsRecovery: true,
1267
- });
1268
- }
1269
- }
1270
-
1246
+
1271
1247
  console.log(JSON.stringify({
1272
1248
  ok: true,
1273
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 {
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { CONFIG_DIR } from '../utils/config.mjs';
3
+ import { CONFIG_DIR, loadConfig } from '../utils/config.mjs';
4
4
 
5
5
  const CONTAINER_ROOT_ENV = process.env.CAMO_CONTAINER_ROOT;
6
6
 
@@ -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
+
@@ -277,6 +277,39 @@ test('mouseWheel retries with refreshed active page after timeout', async () =>
277
277
  restoreTimeout();
278
278
  }
279
279
  });
280
+ test('mouseWheel prefers interactive viewport metrics for anchor clamping', async () => {
281
+ const restoreTimeout = setEnv('CAMO_INPUT_ACTION_TIMEOUT_MS', '80');
282
+ const restoreAttempts = setEnv('CAMO_INPUT_ACTION_MAX_ATTEMPTS', '1');
283
+ const restoreDelay = setEnv('CAMO_INPUT_RECOVERY_DELAY_MS', '0');
284
+ const restoreBringToFrontTimeout = setEnv('CAMO_INPUT_RECOVERY_BRING_TO_FRONT_TIMEOUT_MS', '50');
285
+ const restoreReadySettle = setEnv('CAMO_INPUT_READY_SETTLE_MS', '0');
286
+ try {
287
+ const moves = [];
288
+ const page = {
289
+ isClosed: () => false,
290
+ viewportSize: () => ({ width: 1280, height: 720 }),
291
+ evaluate: async () => ({ innerWidth: 2560, innerHeight: 1440, visualWidth: 2560, visualHeight: 1440 }),
292
+ bringToFront: async () => { },
293
+ waitForTimeout: async () => { },
294
+ mouse: {
295
+ move: async (x, y) => {
296
+ moves.push([x, y]);
297
+ },
298
+ wheel: async () => { },
299
+ },
300
+ };
301
+ const session = createSessionWithPage(page);
302
+ await session.mouseWheel({ deltaY: 360, anchorX: 2564, anchorY: 228 });
303
+ assert.deepEqual(moves, [[2559, 228]]);
304
+ }
305
+ finally {
306
+ restoreReadySettle();
307
+ restoreBringToFrontTimeout();
308
+ restoreDelay();
309
+ restoreAttempts();
310
+ restoreTimeout();
311
+ }
312
+ });
280
313
  test('mouseWheel falls back to keyboard paging when wheel keeps timing out', async () => {
281
314
  const restoreTimeout = setEnv('CAMO_INPUT_ACTION_TIMEOUT_MS', '80');
282
315
  const restoreAttempts = setEnv('CAMO_INPUT_ACTION_MAX_ATTEMPTS', '1');
@@ -1,4 +1,30 @@
1
1
  import { isTimeoutLikeError } from './utils.js';
2
+
3
+ async function readInteractiveViewport(page) {
4
+ const fallback = page.viewportSize?.() || null;
5
+ try {
6
+ const metrics = await page.evaluate(() => ({
7
+ innerWidth: Number(window.innerWidth || 0),
8
+ innerHeight: Number(window.innerHeight || 0),
9
+ visualWidth: Number(window.visualViewport?.width || 0),
10
+ visualHeight: Number(window.visualViewport?.height || 0),
11
+ }));
12
+ const width = Math.max(Number(metrics?.innerWidth || 0), Number(metrics?.visualWidth || 0), Number(fallback?.width || 0));
13
+ const height = Math.max(Number(metrics?.innerHeight || 0), Number(metrics?.visualHeight || 0), Number(fallback?.height || 0));
14
+ if (Number.isFinite(width) && width > 1 && Number.isFinite(height) && height > 1) {
15
+ return {
16
+ width: Math.round(width),
17
+ height: Math.round(height),
18
+ };
19
+ }
20
+ }
21
+ catch { }
22
+ return {
23
+ width: Math.max(1, Number(fallback?.width || 1280)),
24
+ height: Math.max(1, Number(fallback?.height || 720)),
25
+ };
26
+ }
27
+
2
28
  export class BrowserSessionInputOps {
3
29
  ensurePrimaryPage;
4
30
  ensureInputReady;
@@ -89,7 +115,7 @@ export class BrowserSessionInputOps {
89
115
  }
90
116
  try {
91
117
  await this.runInputAction(page, 'mouse:wheel', async (activePage) => {
92
- const viewport = activePage.viewportSize();
118
+ const viewport = await readInteractiveViewport(activePage);
93
119
  const moveX = Number.isFinite(normalizedAnchorX)
94
120
  ? Math.max(1, Math.min(Math.max(1, Number(viewport?.width || 1280) - 1), Math.round(normalizedAnchorX)))
95
121
  : Math.max(1, Math.floor(((viewport?.width || 1280) * 0.5)));
@@ -6,6 +6,36 @@ export class BrowserSessionPageManagement {
6
6
  constructor(deps) {
7
7
  this.deps = deps;
8
8
  }
9
+ async openPageViaContext(ctx, beforeCount) {
10
+ try {
11
+ const page = await ctx.newPage();
12
+ await page.waitForLoadState('domcontentloaded', { timeout: 1500 }).catch(() => null);
13
+ const after = ctx.pages().filter((p) => !p.isClosed()).length;
14
+ if (after > beforeCount) {
15
+ return page;
16
+ }
17
+ }
18
+ catch {
19
+ // Fall through to shortcut-based creation below.
20
+ }
21
+ return null;
22
+ }
23
+ async openPageViaShortcut(ctx, opener, shortcut, beforeCount) {
24
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
25
+ const waitPage = ctx.waitForEvent('page', { timeout: 1200 }).catch(() => null);
26
+ await opener.keyboard.press(shortcut).catch(() => null);
27
+ const page = await waitPage;
28
+ const pagesNow = ctx.pages().filter((p) => !p.isClosed());
29
+ const after = pagesNow.length;
30
+ if (page && after > beforeCount)
31
+ return page;
32
+ if (!page && after > beforeCount) {
33
+ return pagesNow[pagesNow.length - 1] || null;
34
+ }
35
+ await new Promise((r) => setTimeout(r, 250));
36
+ }
37
+ return null;
38
+ }
9
39
  tryOsNewTabShortcut() {
10
40
  if (this.deps.isHeadless())
11
41
  return false;
@@ -58,7 +88,14 @@ export class BrowserSessionPageManagement {
58
88
  }
59
89
  listPages() {
60
90
  const ctx = this.deps.ensureContext();
61
- const pages = ctx.pages().filter((p) => !p.isClosed());
91
+ // Filter out closed pages AND pages that are effectively blank (about:newtab/about:blank)
92
+ const pages = ctx.pages().filter((p) => {
93
+ if (p.isClosed()) return false;
94
+ const url = p.url();
95
+ // Filter out blank placeholder pages
96
+ if (url === 'about:newtab' || url === 'about:blank') return false;
97
+ return true;
98
+ });
62
99
  const active = this.deps.getActivePage();
63
100
  return pages.map((p, index) => ({
64
101
  index,
@@ -78,23 +115,15 @@ export class BrowserSessionPageManagement {
78
115
  await opener.bringToFront().catch(() => null);
79
116
  }
80
117
  const before = ctx.pages().filter((p) => !p.isClosed()).length;
81
- for (let attempt = 1; attempt <= 3; attempt += 1) {
82
- const waitPage = ctx.waitForEvent('page', { timeout: 8000 }).catch(() => null);
83
- await opener.keyboard.press(shortcut).catch(() => null);
84
- page = await waitPage;
85
- const pagesNow = ctx.pages().filter((p) => !p.isClosed());
86
- const after = pagesNow.length;
87
- if (page && after > before)
88
- break;
89
- if (!page && after > before) {
90
- page = pagesNow[pagesNow.length - 1] || null;
91
- break;
92
- }
93
- await new Promise((r) => setTimeout(r, 250));
118
+ if (!options?.strictShortcut) {
119
+ page = await this.openPageViaContext(ctx, before);
120
+ }
121
+ if (!page) {
122
+ page = await this.openPageViaShortcut(ctx, opener, shortcut, before);
94
123
  }
95
124
  let after = ctx.pages().filter((p) => !p.isClosed()).length;
96
125
  if (!page || after <= before) {
97
- const waitPage = ctx.waitForEvent('page', { timeout: 8000 }).catch(() => null);
126
+ const waitPage = ctx.waitForEvent('page', { timeout: 1200 }).catch(() => null);
98
127
  const osShortcutOk = this.tryOsNewTabShortcut();
99
128
  if (osShortcutOk) {
100
129
  page = await waitPage;
@@ -107,13 +136,7 @@ export class BrowserSessionPageManagement {
107
136
  }
108
137
  if (!page || after <= before) {
109
138
  if (!options?.strictShortcut) {
110
- try {
111
- page = await ctx.newPage();
112
- await page.waitForLoadState('domcontentloaded', { timeout: 8000 }).catch(() => null);
113
- }
114
- catch {
115
- // ignore fallback errors
116
- }
139
+ page = await this.openPageViaContext(ctx, before);
117
140
  after = ctx.pages().filter((p) => !p.isClosed()).length;
118
141
  if (!page && after > before) {
119
142
  const pagesNow = ctx.pages().filter((p) => !p.isClosed());
@@ -194,8 +217,39 @@ export class BrowserSessionPageManagement {
194
217
  throw new Error(`invalid_page_index: ${index}`);
195
218
  }
196
219
  const page = pages[closedIndex];
197
- await page.close().catch(() => { });
198
- const remaining = ctx.pages().filter((p) => !p.isClosed());
220
+ const beforeUrl = page.url();
221
+
222
+ // Try to close the page
223
+ try {
224
+ await page.close({ runBeforeUnload: false });
225
+ } catch (e) {
226
+ // Ignore close errors
227
+ }
228
+
229
+ // Wait for close to take effect
230
+ await new Promise(r => setTimeout(r, 100));
231
+
232
+ // Check if actually closed
233
+ let remaining = ctx.pages().filter((p) => !p.isClosed());
234
+
235
+ // If still same count, the page might not have closed properly
236
+ // Try navigating to about:blank first then close
237
+ if (remaining.length === pages.length) {
238
+ try {
239
+ await page.goto('about:blank', { timeout: 500 }).catch(() => {});
240
+ await page.close({ runBeforeUnload: false }).catch(() => {});
241
+ await new Promise(r => setTimeout(r, 100));
242
+ remaining = ctx.pages().filter((p) => !p.isClosed());
243
+ } catch (e) {
244
+ // Ignore
245
+ }
246
+ }
247
+
248
+ // Final check - filter out pages that look like closed tabs (about:newtab)
249
+ remaining = remaining.filter(p => {
250
+ const url = p.url();
251
+ return url !== 'about:newtab' && url !== 'about:blank';
252
+ });
199
253
  const nextIndex = remaining.length === 0 ? -1 : Math.min(Math.max(0, closedIndex - 1), remaining.length - 1);
200
254
  if (nextIndex >= 0) {
201
255
  const nextPage = remaining[nextIndex];
@@ -0,0 +1,105 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { BrowserSessionPageManagement } from './page-management.js';
4
+
5
+ function createPage(label) {
6
+ const page = {
7
+ label,
8
+ closed: false,
9
+ bringToFrontCalls: 0,
10
+ gotoCalls: [],
11
+ waitCalls: [],
12
+ keyboard: {
13
+ presses: [],
14
+ press: async (shortcut) => {
15
+ page.keyboard.presses.push(shortcut);
16
+ },
17
+ },
18
+ url: () => `https://example.com/${label}`,
19
+ isClosed() {
20
+ return this.closed;
21
+ },
22
+ async bringToFront() {
23
+ this.bringToFrontCalls += 1;
24
+ },
25
+ async waitForLoadState(_state, opts) {
26
+ this.waitCalls.push(Number(opts?.timeout || 0));
27
+ },
28
+ async goto(url) {
29
+ this.gotoCalls.push(url);
30
+ },
31
+ };
32
+ return page;
33
+ }
34
+
35
+ function createManagement({ pages, activePage, ctxNewPage, waitForEvent }) {
36
+ let currentActive = activePage;
37
+ const ctx = {
38
+ pages: () => pages,
39
+ newPage: ctxNewPage,
40
+ waitForEvent: waitForEvent || (async () => null),
41
+ };
42
+ const management = new BrowserSessionPageManagement({
43
+ ensureContext: () => ctx,
44
+ getActivePage: () => currentActive,
45
+ getCurrentUrl: () => currentActive?.url?.() || null,
46
+ setActivePage: (page) => {
47
+ currentActive = page ?? null;
48
+ },
49
+ setupPageHooks: () => { },
50
+ ensurePageViewport: async () => { },
51
+ maybeCenterPage: async () => { },
52
+ recordLastKnownUrl: () => { },
53
+ isHeadless: () => false,
54
+ });
55
+ return { management, getActivePage: () => currentActive };
56
+ }
57
+
58
+ test('newPage prefers direct context creation before shortcut retries', async () => {
59
+ const opener = createPage('opener');
60
+ const created = createPage('created');
61
+ const pages = [opener];
62
+ let ctxNewPageCalls = 0;
63
+ const { management, getActivePage } = createManagement({
64
+ pages,
65
+ activePage: opener,
66
+ ctxNewPage: async () => {
67
+ ctxNewPageCalls += 1;
68
+ pages.push(created);
69
+ return created;
70
+ },
71
+ waitForEvent: async () => {
72
+ throw new Error('shortcut path should not run');
73
+ },
74
+ });
75
+ const result = await management.newPage();
76
+ assert.equal(ctxNewPageCalls, 1);
77
+ assert.equal(opener.keyboard.presses.length, 0);
78
+ assert.equal(result.index, 1);
79
+ assert.equal(result.url, 'https://example.com/created');
80
+ assert.equal(getActivePage(), created);
81
+ });
82
+
83
+ test('newPage falls back to shortcut path in strictShortcut mode', async () => {
84
+ const opener = createPage('opener');
85
+ const created = createPage('created');
86
+ const pages = [opener];
87
+ let ctxNewPageCalls = 0;
88
+ const { management, getActivePage } = createManagement({
89
+ pages,
90
+ activePage: opener,
91
+ ctxNewPage: async () => {
92
+ ctxNewPageCalls += 1;
93
+ return created;
94
+ },
95
+ waitForEvent: async () => {
96
+ pages.push(created);
97
+ return created;
98
+ },
99
+ });
100
+ const result = await management.newPage(undefined, { strictShortcut: true });
101
+ assert.equal(ctxNewPageCalls, 0);
102
+ assert.ok(opener.keyboard.presses.length >= 1);
103
+ assert.equal(result.index, 1);
104
+ assert.equal(getActivePage(), created);
105
+ });
@@ -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};
@@ -69,7 +69,7 @@ export const CONFIG_FILE = path.join(CONFIG_DIR, 'camo-cli.json');
69
69
  export const PROFILE_META_FILE = 'camo-profile.json';
70
70
  export const BROWSER_SERVICE_URL = process.env.CAMO_BROWSER_URL
71
71
  || process.env.CAMO_BROWSER_HTTP_URL
72
- || process.env.CAMO_BROWSER_HOST
72
+ || (process.env.CAMO_BROWSER_HOST ? `http://${process.env.CAMO_BROWSER_HOST}` : '')
73
73
  || 'http://127.0.0.1:7704';
74
74
 
75
75
  export function ensureDir(p) {
@@ -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]
@@ -184,6 +184,27 @@ ENV:
184
184
  CAMO_SKIP_BRING_TO_FRONT Legacy alias for CAMO_BRING_TO_FRONT_MODE=never
185
185
  CAMO_PROGRESS_EVENTS_FILE Optional path override for progress jsonl
186
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
187
208
  `);
188
209
  }
189
210