@web-auto/camo 0.1.26 → 0.2.1

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.
Files changed (117) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +586 -586
  3. package/bin/browser-service.mjs +11 -11
  4. package/bin/camo.mjs +22 -22
  5. package/package.json +48 -48
  6. package/scripts/build.mjs +19 -19
  7. package/scripts/bump-version.mjs +34 -34
  8. package/scripts/check-file-size.mjs +80 -80
  9. package/scripts/file-size-policy.json +12 -2
  10. package/scripts/install.mjs +76 -76
  11. package/scripts/release.sh +54 -54
  12. package/src/autoscript/action-providers/index.mjs +6 -6
  13. package/src/autoscript/impact-engine.mjs +78 -78
  14. package/src/autoscript/runtime.mjs +1017 -1017
  15. package/src/autoscript/schema.mjs +376 -376
  16. package/src/cli.mjs +405 -405
  17. package/src/commands/attach.mjs +141 -141
  18. package/src/commands/autoscript.mjs +1011 -1011
  19. package/src/commands/browser.mjs +1255 -1257
  20. package/src/commands/container.mjs +401 -401
  21. package/src/commands/cookies.mjs +69 -69
  22. package/src/commands/create.mjs +98 -98
  23. package/src/commands/devtools.mjs +349 -349
  24. package/src/commands/events.mjs +152 -152
  25. package/src/commands/highlight-mode.mjs +24 -24
  26. package/src/commands/init.mjs +68 -68
  27. package/src/commands/lifecycle.mjs +275 -275
  28. package/src/commands/mouse.mjs +45 -45
  29. package/src/commands/profile.mjs +46 -46
  30. package/src/commands/record.mjs +115 -115
  31. package/src/commands/system.mjs +14 -14
  32. package/src/commands/window.mjs +123 -123
  33. package/src/container/change-notifier.mjs +362 -362
  34. package/src/container/element-filter.mjs +143 -143
  35. package/src/container/index.mjs +3 -3
  36. package/src/container/runtime-core/checkpoint.mjs +209 -209
  37. package/src/container/runtime-core/index.mjs +21 -21
  38. package/src/container/runtime-core/operations/index.mjs +774 -774
  39. package/src/container/runtime-core/operations/selector-scripts.mjs +277 -277
  40. package/src/container/runtime-core/operations/tab-pool.mjs +746 -746
  41. package/src/container/runtime-core/operations/viewport.mjs +189 -189
  42. package/src/container/runtime-core/search.mjs +190 -190
  43. package/src/container/runtime-core/subscription.mjs +224 -224
  44. package/src/container/runtime-core/utils.mjs +94 -94
  45. package/src/container/runtime-core/validation.mjs +127 -184
  46. package/src/container/runtime-core.mjs +1 -1
  47. package/src/container/subscription-registry.mjs +459 -459
  48. package/src/core/actions.mjs +561 -561
  49. package/src/core/browser.mjs +266 -266
  50. package/src/core/index.mjs +52 -52
  51. package/src/core/utils.mjs +91 -91
  52. package/src/events/daemon-entry.mjs +33 -33
  53. package/src/events/daemon.mjs +80 -80
  54. package/src/events/progress-log.mjs +109 -109
  55. package/src/events/ws-server.mjs +239 -239
  56. package/src/lib/client.mjs +200 -200
  57. package/src/lifecycle/cleanup.mjs +83 -83
  58. package/src/lifecycle/lock.mjs +126 -126
  59. package/src/lifecycle/session-registry.mjs +279 -279
  60. package/src/lifecycle/session-view.mjs +76 -76
  61. package/src/lifecycle/session-watchdog.mjs +281 -281
  62. package/src/services/browser-service/index.js +671 -674
  63. package/src/services/browser-service/internal/BrowserSession.input.test.js +389 -389
  64. package/src/services/browser-service/internal/BrowserSession.js +325 -336
  65. package/src/services/browser-service/internal/ElementRegistry.js +60 -60
  66. package/src/services/browser-service/internal/ProfileLock.js +84 -84
  67. package/src/services/browser-service/internal/SessionManager.js +184 -184
  68. package/src/services/browser-service/internal/SessionManager.test.js +39 -39
  69. package/src/services/browser-service/internal/browser-session/cookies.js +144 -144
  70. package/src/services/browser-service/internal/browser-session/input-ops.js +222 -219
  71. package/src/services/browser-service/internal/browser-session/input-pipeline.js +144 -144
  72. package/src/services/browser-service/internal/browser-session/logging.js +46 -46
  73. package/src/services/browser-service/internal/browser-session/navigation.js +38 -38
  74. package/src/services/browser-service/internal/browser-session/page-hooks.js +442 -442
  75. package/src/services/browser-service/internal/browser-session/page-management.js +302 -336
  76. package/src/services/browser-service/internal/browser-session/page-management.test.js +148 -148
  77. package/src/services/browser-service/internal/browser-session/recording.js +198 -198
  78. package/src/services/browser-service/internal/browser-session/runtime-events.js +61 -61
  79. package/src/services/browser-service/internal/browser-session/session-core.js +84 -84
  80. package/src/services/browser-service/internal/browser-session/session-state.js +38 -38
  81. package/src/services/browser-service/internal/browser-session/types.js +14 -14
  82. package/src/services/browser-service/internal/browser-session/utils.js +95 -95
  83. package/src/services/browser-service/internal/browser-session/viewport-manager.js +46 -46
  84. package/src/services/browser-service/internal/browser-session/viewport.js +215 -215
  85. package/src/services/browser-service/internal/container-matcher.js +851 -851
  86. package/src/services/browser-service/internal/container-registry.js +182 -182
  87. package/src/services/browser-service/internal/engine-manager.js +259 -259
  88. package/src/services/browser-service/internal/fingerprint.js +203 -203
  89. package/src/services/browser-service/internal/heartbeat.js +137 -137
  90. package/src/services/browser-service/internal/logging.js +46 -46
  91. package/src/services/browser-service/internal/page-runtime/runtime.js +1317 -1317
  92. package/src/services/browser-service/internal/pageRuntime.js +28 -28
  93. package/src/services/browser-service/internal/runtimeInjector.js +31 -31
  94. package/src/services/browser-service/internal/service-process-logger.js +140 -140
  95. package/src/services/browser-service/internal/state-bus.js +45 -45
  96. package/src/services/browser-service/internal/storage-paths.js +42 -42
  97. package/src/services/browser-service/internal/ws-server.js +1194 -1194
  98. package/src/services/browser-service/internal/ws-server.test.js +58 -58
  99. package/src/services/browser-service/server.mjs +6 -6
  100. package/src/services/controller/cli-bridge.js +93 -93
  101. package/src/services/controller/container-index.js +50 -50
  102. package/src/services/controller/container-storage.js +36 -36
  103. package/src/services/controller/controller-actions.js +207 -207
  104. package/src/services/controller/controller.js +1138 -1138
  105. package/src/services/controller/selectors.js +54 -54
  106. package/src/services/controller/transport.js +125 -125
  107. package/src/utils/args.mjs +26 -26
  108. package/src/utils/browser-service.mjs +544 -544
  109. package/src/utils/command-log.mjs +64 -64
  110. package/src/utils/config.mjs +214 -214
  111. package/src/utils/fingerprint.mjs +181 -181
  112. package/src/utils/help.mjs +216 -216
  113. package/src/utils/js-policy.mjs +13 -13
  114. package/src/utils/ws-client.mjs +30 -30
  115. package/src/container/runtime-core/operations/tab-pool.mjs.bak +0 -762
  116. package/src/container/runtime-core/operations/tab-pool.mjs.syntax-error +0 -762
  117. package/src/services/browser-service/index.js.bak +0 -671
@@ -1,1257 +1,1255 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import {
4
- listProfiles,
5
- getDefaultProfile,
6
- getHighlightMode,
7
- getProfileWindowSize,
8
- setProfileWindowSize,
9
- } from '../utils/config.mjs';
10
- import { callAPI, ensureCamoufox, ensureBrowserService, getSessionByProfile, checkBrowserService, getResolvedSessions } from '../utils/browser-service.mjs';
11
- import { resolveProfileId, ensureUrlScheme, looksLikeUrlToken, getPositionals } from '../utils/args.mjs';
12
- import { ensureJsExecutionEnabled } from '../utils/js-policy.mjs';
13
- import { acquireLock, releaseLock, cleanupStaleLocks } from '../lifecycle/lock.mjs';
14
- import {
15
- buildScrollTargetScript,
16
- } from '../container/runtime-core/operations/selector-scripts.mjs';
17
- import {
18
- registerSession,
19
- updateSession,
20
- getSessionInfo,
21
- unregisterSession,
22
- listRegisteredSessions,
23
- markSessionClosed,
24
- cleanupStaleSessions,
25
- resolveSessionTarget,
26
- isSessionAliasTaken,
27
- } from '../lifecycle/session-registry.mjs';
28
- import { startSessionWatchdog, stopAllSessionWatchdogs, stopSessionWatchdog } from '../lifecycle/session-watchdog.mjs';
29
-
30
- const START_WINDOW_MIN_WIDTH = 960;
31
- const START_WINDOW_MIN_HEIGHT = 700;
32
- const START_WINDOW_MAX_RESERVE = 240;
33
- const START_WINDOW_DEFAULT_RESERVE = 0;
34
- const DEFAULT_HEADLESS_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
35
- const DEVTOOLS_SHORTCUTS = process.platform === 'darwin'
36
- ? ['Meta+Alt+I', 'F12']
37
- : ['F12', 'Control+Shift+I'];
38
- const INPUT_ACTION_TIMEOUT_MS = Math.max(
39
- 1000,
40
- parseNumber(process.env.CAMO_INPUT_ACTION_TIMEOUT_MS, 30000),
41
- );
42
-
43
- function sleep(ms) {
44
- return new Promise((resolve) => setTimeout(resolve, ms));
45
- }
46
-
47
- function parseNumber(value, fallback = 0) {
48
- const parsed = Number(value);
49
- return Number.isFinite(parsed) ? parsed : fallback;
50
- }
51
-
52
- function clamp(value, min, max) {
53
- return Math.min(Math.max(value, min), max);
54
- }
55
-
56
- function readFlagValue(args, names) {
57
- for (let i = 0; i < args.length; i += 1) {
58
- if (!names.includes(args[i])) continue;
59
- const value = args[i + 1];
60
- if (!value || String(value).startsWith('-')) return null;
61
- return value;
62
- }
63
- return null;
64
- }
65
-
66
- function parseDurationMs(raw, fallbackMs) {
67
- if (raw === undefined || raw === null || String(raw).trim() === '') return fallbackMs;
68
- const text = String(raw).trim().toLowerCase();
69
- if (text === '0' || text === 'off' || text === 'none' || text === 'disable' || text === 'disabled') return 0;
70
- const matched = text.match(/^(\d+(?:\.\d+)?)(ms|s|m|h)?$/);
71
- if (!matched) {
72
- throw new Error('Invalid --idle-timeout. Use forms like 30m, 1800s, 5000ms, 1h, 0');
73
- }
74
- const value = Number(matched[1]);
75
- if (!Number.isFinite(value) || value < 0) {
76
- throw new Error('Invalid --idle-timeout value');
77
- }
78
- const unit = matched[2] || 'm';
79
- const factor = unit === 'h' ? 3600000 : unit === 'm' ? 60000 : unit === 's' ? 1000 : 1;
80
- return Math.floor(value * factor);
81
- }
82
-
83
- function assertExistingProfile(profileId, profileSet = null) {
84
- const id = String(profileId || '').trim();
85
- if (!id) throw new Error('profileId is required');
86
- const known = profileSet || new Set(listProfiles());
87
- if (!known.has(id)) {
88
- throw new Error(`profile not found: ${id}. create it first with "camo profile create ${id}"`);
89
- }
90
- return id;
91
- }
92
-
93
- async function resolveVisibleTargetPoint(profileId, selector, options = {}) {
94
- const selectorLiteral = JSON.stringify(String(selector || '').trim());
95
- const highlight = options.highlight === true;
96
- const payload = await callAPI('evaluate', {
97
- profileId,
98
- script: `(() => {
99
- const selector = ${selectorLiteral};
100
- const highlight = ${highlight ? 'true' : 'false'};
101
- const nodes = Array.from(document.querySelectorAll(selector));
102
- const isVisible = (node) => {
103
- if (!(node instanceof Element)) return false;
104
- const rect = node.getBoundingClientRect?.();
105
- if (!rect || rect.width <= 0 || rect.height <= 0) return false;
106
- try {
107
- const style = window.getComputedStyle(node);
108
- if (!style) return false;
109
- if (style.display === 'none') return false;
110
- if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
111
- const opacity = Number.parseFloat(String(style.opacity || '1'));
112
- if (Number.isFinite(opacity) && opacity <= 0.01) return false;
113
- } catch {
114
- return false;
115
- }
116
- return true;
117
- };
118
- const hitVisible = (node) => {
119
- if (!(node instanceof Element)) return false;
120
- const rect = node.getBoundingClientRect?.();
121
- if (!rect) return false;
122
- const x = Math.max(0, Math.min((window.innerWidth || 1) - 1, Math.round(rect.left + rect.width / 2)));
123
- const y = Math.max(0, Math.min((window.innerHeight || 1) - 1, Math.round(rect.top + rect.height / 2)));
124
- const top = document.elementFromPoint(x, y);
125
- if (!top) return false;
126
- return top === node || node.contains(top) || top.contains(node);
127
- };
128
- const target = nodes.find((item) => isVisible(item) && hitVisible(item))
129
- || nodes.find((item) => isVisible(item))
130
- || nodes[0]
131
- || null;
132
- if (!target) {
133
- return { ok: false, error: 'selector_not_found', selector };
134
- }
135
- const rect = target.getBoundingClientRect?.() || { left: 0, top: 0, width: 1, height: 1 };
136
- const center = {
137
- x: Math.max(1, Math.min((window.innerWidth || 1) - 1, Math.round(rect.left + Math.max(1, rect.width / 2)))),
138
- y: Math.max(1, Math.min((window.innerHeight || 1) - 1, Math.round(rect.top + Math.max(1, rect.height / 2)))),
139
- };
140
- if (highlight) {
141
- try {
142
- const id = 'camo-action-highlight-overlay';
143
- const old = document.getElementById(id);
144
- if (old) old.remove();
145
- const overlay = document.createElement('div');
146
- overlay.id = id;
147
- overlay.style.position = 'fixed';
148
- overlay.style.left = rect.left + 'px';
149
- overlay.style.top = rect.top + 'px';
150
- overlay.style.width = rect.width + 'px';
151
- overlay.style.height = rect.height + 'px';
152
- overlay.style.border = '2px solid #00A8FF';
153
- overlay.style.borderRadius = '8px';
154
- overlay.style.background = 'rgba(0,168,255,0.12)';
155
- overlay.style.pointerEvents = 'none';
156
- overlay.style.zIndex = '2147483647';
157
- overlay.style.transition = 'opacity 120ms ease';
158
- overlay.style.opacity = '1';
159
- document.documentElement.appendChild(overlay);
160
- setTimeout(() => {
161
- overlay.style.opacity = '0';
162
- setTimeout(() => overlay.remove(), 180);
163
- }, 260);
164
- } catch {}
165
- }
166
- return {
167
- ok: true,
168
- selector,
169
- center,
170
- rect: {
171
- left: rect.left,
172
- top: rect.top,
173
- width: rect.width,
174
- height: rect.height,
175
- },
176
- viewport: {
177
- width: Number(window.innerWidth || 0),
178
- height: Number(window.innerHeight || 0),
179
- },
180
- };
181
- })()`,
182
- }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
183
- const result = payload?.result || payload?.data?.result || payload?.data || payload || null;
184
- if (!result || result.ok !== true || !result.center) {
185
- throw new Error(`Element not found: ${selector}`);
186
- }
187
- return result;
188
- }
189
-
190
- function isTargetFullyInViewport(target, margin = 6) {
191
- const rect = target?.rect && typeof target.rect === 'object' ? target.rect : null;
192
- const viewport = target?.viewport && typeof target.viewport === 'object' ? target.viewport : null;
193
- if (!rect || !viewport) return true;
194
- const vw = Number(viewport.width || 0);
195
- const vh = Number(viewport.height || 0);
196
- if (!Number.isFinite(vw) || !Number.isFinite(vh) || vw <= 0 || vh <= 0) return true;
197
- const left = Number(rect.left || 0);
198
- const top = Number(rect.top || 0);
199
- const width = Math.max(0, Number(rect.width || 0));
200
- const height = Math.max(0, Number(rect.height || 0));
201
- const right = left + width;
202
- const bottom = top + height;
203
- const m = Math.max(0, Number(margin) || 0);
204
- return left >= m && top >= m && right <= (vw - m) && bottom <= (vh - m);
205
- }
206
-
207
- async function ensureClickTargetInViewport(profileId, selector, initialTarget, options = {}) {
208
- let target = initialTarget;
209
- const maxSteps = Math.max(0, Number(options.maxAutoScrollSteps ?? 8) || 8);
210
- const settleMs = Math.max(0, Number(options.autoScrollSettleMs ?? 140) || 140);
211
- let autoScrolled = 0;
212
-
213
- while (autoScrolled < maxSteps && !isTargetFullyInViewport(target)) {
214
- const rect = target?.rect && typeof target.rect === 'object' ? target.rect : {};
215
- const viewport = target?.viewport && typeof target.viewport === 'object' ? target.viewport : {};
216
- const vw = Math.max(1, Number(viewport.width || 1));
217
- const vh = Math.max(1, Number(viewport.height || 1));
218
- const rawCenterY = Number(rect.top || 0) + Math.max(1, Number(rect.height || 0)) / 2;
219
- const desiredCenterY = clamp(Math.round(vh * 0.45), 80, Math.max(80, vh - 80));
220
- let deltaY = Math.round(rawCenterY - desiredCenterY);
221
- deltaY = clamp(deltaY, -900, 900);
222
- if (Math.abs(deltaY) < 100) {
223
- deltaY = deltaY >= 0 ? 120 : -120;
224
- }
225
- await callAPI('mouse:wheel', { profileId, deltaX: 0, deltaY }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
226
- autoScrolled += 1;
227
- if (settleMs > 0) {
228
- await sleep(settleMs);
229
- }
230
- target = await resolveVisibleTargetPoint(profileId, selector, { highlight: false });
231
- }
232
-
233
- return {
234
- target,
235
- autoScrolled,
236
- targetFullyVisible: isTargetFullyInViewport(target),
237
- };
238
- }
239
-
240
- function validateAlias(alias) {
241
- const text = String(alias || '').trim();
242
- if (!text) return null;
243
- if (!/^[a-zA-Z0-9._-]+$/.test(text)) {
244
- throw new Error('Invalid --alias. Use only letters, numbers, dot, underscore, dash.');
245
- }
246
- return text.slice(0, 64);
247
- }
248
-
249
- function resolveHighlightEnabled(args) {
250
- if (args.includes('--highlight')) return true;
251
- if (args.includes('--no-highlight')) return false;
252
- return getHighlightMode();
253
- }
254
-
255
- function formatDurationMs(ms) {
256
- const value = Number(ms);
257
- if (!Number.isFinite(value) || value <= 0) return 'disabled';
258
- if (value % 3600000 === 0) return `${value / 3600000}h`;
259
- if (value % 60000 === 0) return `${value / 60000}m`;
260
- if (value % 1000 === 0) return `${value / 1000}s`;
261
- return `${value}ms`;
262
- }
263
-
264
- function computeIdleState(session, now = Date.now()) {
265
- const headless = session?.headless === true;
266
- const timeoutMs = headless
267
- ? (Number.isFinite(Number(session?.idleTimeoutMs)) ? Math.max(0, Number(session.idleTimeoutMs)) : DEFAULT_HEADLESS_IDLE_TIMEOUT_MS)
268
- : 0;
269
- const lastAt = Number(session?.lastActivityAt || session?.lastSeen || session?.startTime || now);
270
- const idleMs = Math.max(0, now - (Number.isFinite(lastAt) ? lastAt : now));
271
- const idle = headless && timeoutMs > 0 && idleMs >= timeoutMs;
272
- return { headless, timeoutMs, idleMs, idle };
273
- }
274
-
275
- async function stopAndCleanupProfile(profileId, options = {}) {
276
- const id = String(profileId || '').trim();
277
- if (!id) return { profileId: id, ok: false, error: 'profile_required' };
278
- const force = options.force === true;
279
- const serviceUp = options.serviceUp === true;
280
- let result = null;
281
- let error = null;
282
- if (serviceUp) {
283
- try {
284
- result = await callAPI('stop', force ? { profileId: id, force: true } : { profileId: id });
285
- } catch (err) {
286
- error = err;
287
- }
288
- }
289
- stopSessionWatchdog(id);
290
- releaseLock(id);
291
- markSessionClosed(id);
292
- return {
293
- profileId: id,
294
- ok: !error,
295
- serviceUp,
296
- result,
297
- error: error ? (error.message || String(error)) : null,
298
- };
299
- }
300
-
301
- async function loadResolvedSessions(serviceUp) {
302
- if (!serviceUp) return [];
303
- try {
304
- return await getResolvedSessions();
305
- } catch {
306
- return [];
307
- }
308
- }
309
-
310
- async function probeViewportSize(profileId) {
311
- try {
312
- const payload = await callAPI('evaluate', {
313
- profileId,
314
- script: '(() => ({ width: Number(window.innerWidth || 0), height: Number(window.innerHeight || 0) }))()',
315
- });
316
- const size = payload?.result || payload?.data || payload || {};
317
- const width = Number(size?.width || 0);
318
- const height = Number(size?.height || 0);
319
- if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
320
- return { width, height };
321
- } catch {
322
- return null;
323
- }
324
- }
325
-
326
- export async function requestDevtoolsOpen(profileId, options = {}) {
327
- const id = String(profileId || '').trim();
328
- if (!id) {
329
- return { ok: false, requested: false, reason: 'profile_required', shortcuts: [] };
330
- }
331
-
332
- const shortcuts = Array.isArray(options.shortcuts) && options.shortcuts.length
333
- ? options.shortcuts.map((item) => String(item || '').trim()).filter(Boolean)
334
- : DEVTOOLS_SHORTCUTS;
335
- const settleMs = Math.max(0, parseNumber(options.settleMs, 180));
336
- const before = await probeViewportSize(id);
337
- const attempts = [];
338
-
339
- for (const key of shortcuts) {
340
- try {
341
- await callAPI('keyboard:press', { profileId: id, key });
342
- attempts.push({ key, ok: true });
343
- if (settleMs > 0) {
344
- // Allow browser UI animation to settle after shortcut.
345
- // eslint-disable-next-line no-await-in-loop
346
- await sleep(settleMs);
347
- }
348
- } catch (err) {
349
- attempts.push({ key, ok: false, error: err?.message || String(err) });
350
- }
351
- }
352
-
353
- const after = await probeViewportSize(id);
354
- const beforeHeight = Number(before?.height || 0);
355
- const afterHeight = Number(after?.height || 0);
356
- const viewportReduced = beforeHeight > 0 && afterHeight > 0 && afterHeight < (beforeHeight - 100);
357
- const successCount = attempts.filter((item) => item.ok).length;
358
-
359
- return {
360
- ok: successCount > 0,
361
- requested: true,
362
- shortcuts,
363
- attempts,
364
- before,
365
- after,
366
- verified: viewportReduced,
367
- verification: viewportReduced ? 'viewport_height_reduced' : 'shortcut_dispatched',
368
- };
369
- }
370
-
371
- export function computeTargetViewportFromWindowMetrics(measured) {
372
- const innerWidth = Math.max(320, parseNumber(measured?.innerWidth, 0));
373
- const innerHeight = Math.max(240, parseNumber(measured?.innerHeight, 0));
374
- const outerWidth = Math.max(320, parseNumber(measured?.outerWidth, innerWidth));
375
- const outerHeight = Math.max(240, parseNumber(measured?.outerHeight, innerHeight));
376
-
377
- const rawDeltaW = Math.max(0, outerWidth - innerWidth);
378
- const rawDeltaH = Math.max(0, outerHeight - innerHeight);
379
- const frameW = rawDeltaW > 400 ? 16 : Math.min(rawDeltaW, 120);
380
- const frameH = rawDeltaH > 400 ? 88 : Math.min(rawDeltaH, 180);
381
-
382
- return {
383
- width: Math.max(320, outerWidth - frameW),
384
- height: Math.max(240, outerHeight - frameH),
385
- frameW,
386
- frameH,
387
- innerWidth,
388
- innerHeight,
389
- outerWidth,
390
- outerHeight,
391
- };
392
- }
393
-
394
- export function computeStartWindowSize(metrics, options = {}) {
395
- const display = metrics?.metrics || metrics || {};
396
- const reserveFromEnv = parseNumber(process.env.CAMO_DEFAULT_WINDOW_VERTICAL_RESERVE, START_WINDOW_DEFAULT_RESERVE);
397
- const reserve = clamp(
398
- parseNumber(options.reservePx, reserveFromEnv),
399
- 0,
400
- START_WINDOW_MAX_RESERVE,
401
- );
402
-
403
- const workWidth = parseNumber(display.workWidth, 0);
404
- const workHeight = parseNumber(display.workHeight, 0);
405
- const width = parseNumber(display.width, 0);
406
- const height = parseNumber(display.height, 0);
407
- const baseW = Math.floor(workWidth > 0 ? workWidth : width);
408
- const baseH = Math.floor(workHeight > 0 ? workHeight : height);
409
-
410
- if (baseW <= 0 || baseH <= 0) {
411
- return {
412
- width: 1920,
413
- height: 1000,
414
- reservePx: reserve,
415
- source: 'fallback',
416
- };
417
- }
418
-
419
- return {
420
- width: Math.max(START_WINDOW_MIN_WIDTH, baseW),
421
- height: Math.max(START_WINDOW_MIN_HEIGHT, baseH - reserve),
422
- reservePx: reserve,
423
- source: workWidth > 0 || workHeight > 0 ? 'workArea' : 'screen',
424
- };
425
- }
426
-
427
- async function probeWindowMetrics(profileId) {
428
- const measured = await callAPI('evaluate', {
429
- profileId,
430
- script: '({ innerWidth: window.innerWidth, innerHeight: window.innerHeight, outerWidth: window.outerWidth, outerHeight: window.outerHeight })',
431
- });
432
- return measured?.result || {};
433
- }
434
-
435
- export async function syncWindowViewportAfterResize(profileId, width, height, options = {}) {
436
- const settleMs = Math.max(40, parseNumber(options.settleMs, 120));
437
- const attempts = Math.max(1, Math.min(8, Math.floor(parseNumber(options.attempts, 4))));
438
- const tolerancePx = Math.max(0, parseNumber(options.tolerancePx, 3));
439
-
440
- const windowResult = await callAPI('window:resize', { profileId, width, height });
441
- await sleep(settleMs);
442
-
443
- let measured = {};
444
- let verified = {};
445
- let viewport = null;
446
- let matched = false;
447
- let target = { width: 1280, height: 720, frameW: 16, frameH: 88 };
448
-
449
- for (let i = 0; i < attempts; i += 1) {
450
- measured = await probeWindowMetrics(profileId);
451
- target = computeTargetViewportFromWindowMetrics(measured);
452
- viewport = await callAPI('page:setViewport', {
453
- profileId,
454
- width: target.width,
455
- height: target.height,
456
- });
457
- await sleep(settleMs);
458
- verified = await probeWindowMetrics(profileId);
459
- const dw = Math.abs(parseNumber(verified?.innerWidth, 0) - target.width);
460
- const dh = Math.abs(parseNumber(verified?.innerHeight, 0) - target.height);
461
- if (dw <= tolerancePx && dh <= tolerancePx) {
462
- matched = true;
463
- break;
464
- }
465
- }
466
-
467
- return {
468
- window: windowResult,
469
- measured,
470
- verified,
471
- targetViewport: {
472
- width: target.width,
473
- height: target.height,
474
- frameW: target.frameW,
475
- frameH: target.frameH,
476
- matched,
477
- },
478
- viewport,
479
- };
480
- }
481
-
482
- export async function handleStartCommand(args) {
483
- ensureCamoufox();
484
- await ensureBrowserService();
485
- cleanupStaleLocks();
486
- cleanupStaleSessions();
487
-
488
- const urlIdx = args.indexOf('--url');
489
- const explicitUrl = urlIdx >= 0 ? args[urlIdx + 1] : undefined;
490
- const widthIdx = args.indexOf('--width');
491
- const heightIdx = args.indexOf('--height');
492
- const explicitWidth = widthIdx >= 0 ? parseNumber(args[widthIdx + 1], NaN) : NaN;
493
- const explicitHeight = heightIdx >= 0 ? parseNumber(args[heightIdx + 1], NaN) : NaN;
494
- const hasExplicitWidth = Number.isFinite(explicitWidth);
495
- const hasExplicitHeight = Number.isFinite(explicitHeight);
496
- const alias = validateAlias(readFlagValue(args, ['--alias']));
497
- const idleTimeoutRaw = readFlagValue(args, ['--idle-timeout']);
498
- const parsedIdleTimeoutMs = parseDurationMs(idleTimeoutRaw, DEFAULT_HEADLESS_IDLE_TIMEOUT_MS);
499
- const maxTabs = Math.max(1, Math.floor(Number(readFlagValue(args, ['--max-tabs']) || 1) || 1));
500
- const wantsDevtools = args.includes('--devtools');
501
- const wantsRecord = args.includes('--record');
502
- const recordName = readFlagValue(args, ['--record-name']);
503
- const recordOutputRaw = readFlagValue(args, ['--record-output']);
504
- const recordOverlay = args.includes('--no-record-overlay')
505
- ? false
506
- : args.includes('--record-overlay')
507
- ? true
508
- : null;
509
- if (hasExplicitWidth !== hasExplicitHeight) {
510
- throw new Error('Usage: camo start [profileId] [--url <url>] [--no-headless|--visible] [--devtools] [--record] [--record-name <name>] [--record-output <path>] [--record-overlay|--no-record-overlay] [--alias <name>] [--idle-timeout <duration>] [--width <w> --height <h> --max-tabs <n>]');
511
- }
512
- if ((hasExplicitWidth && explicitWidth < START_WINDOW_MIN_WIDTH) || (hasExplicitHeight && explicitHeight < START_WINDOW_MIN_HEIGHT)) {
513
- throw new Error(`Window size too small. Minimum is ${START_WINDOW_MIN_WIDTH}x${START_WINDOW_MIN_HEIGHT}`);
514
- }
515
- if (args.includes('--record-name') && !recordName) {
516
- throw new Error('Usage: camo start [profileId] --record-name <name>');
517
- }
518
- if (args.includes('--record-output') && !recordOutputRaw) {
519
- throw new Error('Usage: camo start [profileId] --record-output <path>');
520
- }
521
- const recordOutput = recordOutputRaw ? path.resolve(recordOutputRaw) : null;
522
- const hasExplicitWindowSize = hasExplicitWidth && hasExplicitHeight;
523
- const profileSet = new Set(listProfiles());
524
- let implicitUrl;
525
-
526
- let profileId = null;
527
- for (let i = 1; i < args.length; i++) {
528
- const arg = args[i];
529
- if (arg === '--url') { i++; continue; }
530
- if (arg === '--width' || arg === '--height') { i++; continue; }
531
- if (arg === '--alias' || arg === '--idle-timeout' || arg === '--record-name' || arg === '--record-output' || arg === '--max-tabs') { i++; continue; }
532
- if (arg === '--headless' || arg === '--no-headless' || arg === '--visible') continue;
533
- if (arg === '--record' || arg === '--record-overlay' || arg === '--no-record-overlay') continue;
534
- if (arg.startsWith('--')) continue;
535
-
536
- if (looksLikeUrlToken(arg) && !profileSet.has(arg)) {
537
- implicitUrl = arg;
538
- continue;
539
- }
540
-
541
- profileId = arg;
542
- break;
543
- }
544
-
545
- if (!profileId) {
546
- profileId = getDefaultProfile();
547
- if (!profileId) {
548
- throw new Error('No default profile set. Run: camo profile default <profileId>');
549
- }
550
- }
551
- assertExistingProfile(profileId, profileSet);
552
- if (alias && isSessionAliasTaken(alias, profileId)) {
553
- throw new Error(`Alias is already in use: ${alias}`);
554
- }
555
-
556
- // Check for existing session in browser service
557
- const existing = await getSessionByProfile(profileId);
558
- if (existing) {
559
- // Session exists in browser service - update registry and lock
560
- acquireLock(profileId, { sessionId: existing.session_id || existing.profileId });
561
- const saved = getSessionInfo(profileId);
562
- const record = saved
563
- ? updateSession(profileId, {
564
- sessionId: existing.session_id || existing.profileId,
565
- url: existing.current_url,
566
- mode: existing.mode,
567
- alias: alias || saved.alias || null,
568
- })
569
- : registerSession(profileId, {
570
- sessionId: existing.session_id || existing.profileId,
571
- url: existing.current_url,
572
- mode: existing.mode,
573
- alias: alias || null,
574
- });
575
- const idleState = computeIdleState(record);
576
- const payload = {
577
- ok: true,
578
- sessionId: existing.session_id || existing.profileId,
579
- instanceId: record.instanceId,
580
- profileId,
581
- message: 'Session already running',
582
- url: existing.current_url,
583
- alias: record.alias || null,
584
- idleTimeoutMs: idleState.timeoutMs,
585
- idleTimeout: formatDurationMs(idleState.timeoutMs),
586
- closeHint: {
587
- byProfile: `camo stop ${profileId}`,
588
- byId: `camo stop --id ${record.instanceId}`,
589
- byAlias: record.alias ? `camo stop --alias ${record.alias}` : null,
590
- },
591
- };
592
- const existingMode = String(existing?.mode || record?.mode || '').toLowerCase();
593
- const existingHeadless = existing?.headless === true || existingMode.includes('headless');
594
- if (!existingHeadless && wantsDevtools) {
595
- payload.devtools = await requestDevtoolsOpen(profileId);
596
- }
597
- if (wantsRecord) {
598
- payload.recording = await callAPI('record:start', {
599
- profileId,
600
- ...(recordName ? { name: recordName } : {}),
601
- ...(recordOutput ? { outputPath: recordOutput } : {}),
602
- ...(recordOverlay !== null ? { overlay: recordOverlay } : {}),
603
- });
604
- }
605
- console.log(JSON.stringify(payload, null, 2));
606
- startSessionWatchdog(profileId);
607
- return;
608
- }
609
-
610
- // No session in browser service - check registry for recovery
611
- const registryInfo = getSessionInfo(profileId);
612
- if (registryInfo && registryInfo.status === 'active') {
613
- // Session was active but browser service doesn't have it
614
- // This means service was restarted - clean up and start fresh
615
- unregisterSession(profileId);
616
- releaseLock(profileId);
617
- }
618
-
619
- const headless = !args.includes('--no-headless') && !args.includes('--visible');
620
- if (wantsDevtools && headless) {
621
- throw new Error('--devtools requires --no-headless or --visible mode');
622
- }
623
- const idleTimeoutMs = headless ? parsedIdleTimeoutMs : 0;
624
- const targetUrl = explicitUrl || implicitUrl;
625
- const result = await callAPI('start', {
626
- profileId,
627
- url: targetUrl ? ensureUrlScheme(targetUrl) : undefined,
628
- headless,
629
- devtools: wantsDevtools,
630
- ...(wantsRecord ? { record: true } : {}),
631
- ...(Number.isFinite(maxTabs) ? { maxTabs } : {}),
632
- ...(recordName ? { recordName } : {}),
633
- ...(recordOutput ? { recordOutput } : {}),
634
- ...(recordOverlay !== null ? { recordOverlay } : {}),
635
- });
636
-
637
- if (result?.ok) {
638
- const sessionId = result.sessionId || result.profileId || profileId;
639
- acquireLock(profileId, { sessionId });
640
- const record = registerSession(profileId, {
641
- sessionId,
642
- url: targetUrl,
643
- headless,
644
- alias,
645
- idleTimeoutMs,
646
- lastAction: 'start',
647
- });
648
- startSessionWatchdog(profileId);
649
- result.instanceId = record.instanceId;
650
- result.alias = record.alias || null;
651
- result.idleTimeoutMs = idleTimeoutMs;
652
- result.idleTimeout = formatDurationMs(idleTimeoutMs);
653
- result.closeHint = {
654
- byProfile: `camo stop ${profileId}`,
655
- byId: `camo stop --id ${record.instanceId}`,
656
- byAlias: record.alias ? `camo stop --alias ${record.alias}` : null,
657
- all: 'camo close all',
658
- };
659
- result.message = headless
660
- ? `Started session. Idle timeout: ${formatDurationMs(idleTimeoutMs)}`
661
- : 'Started visible session. Remember to stop it when finished.';
662
-
663
- if (!headless) {
664
- let windowTarget = null;
665
- if (hasExplicitWindowSize) {
666
- windowTarget = {
667
- width: Math.floor(explicitWidth),
668
- height: Math.floor(explicitHeight),
669
- source: 'explicit',
670
- };
671
- } else {
672
- const display = await callAPI('system:display', {}).catch(() => null);
673
- const displayTarget = computeStartWindowSize(display);
674
- const rememberedWindow = getProfileWindowSize(profileId);
675
- if (rememberedWindow) {
676
- const rememberedTarget = {
677
- width: rememberedWindow.width,
678
- height: rememberedWindow.height,
679
- source: 'profile',
680
- updatedAt: rememberedWindow.updatedAt,
681
- };
682
- const canTrustDisplayTarget = displayTarget?.source && displayTarget.source !== 'fallback';
683
- const refreshFromDisplay = canTrustDisplayTarget
684
- && (
685
- rememberedTarget.height < Math.floor(displayTarget.height * 0.92)
686
- || rememberedTarget.width < Math.floor(displayTarget.width * 0.92)
687
- );
688
- windowTarget = refreshFromDisplay ? {
689
- ...displayTarget,
690
- source: 'display',
691
- } : rememberedTarget;
692
- } else {
693
- windowTarget = displayTarget;
694
- }
695
- }
696
-
697
- result.startWindow = {
698
- width: windowTarget.width,
699
- height: windowTarget.height,
700
- source: windowTarget.source,
701
- };
702
-
703
- const syncResult = await syncWindowViewportAfterResize(
704
- profileId,
705
- windowTarget.width,
706
- windowTarget.height,
707
- ).catch((err) => ({ error: err?.message || String(err) }));
708
- result.windowSync = syncResult;
709
-
710
- const measuredOuterWidth = Number(syncResult?.verified?.outerWidth);
711
- const measuredOuterHeight = Number(syncResult?.verified?.outerHeight);
712
- const savedWindow = setProfileWindowSize(
713
- profileId,
714
- Number.isFinite(measuredOuterWidth) ? measuredOuterWidth : windowTarget.width,
715
- Number.isFinite(measuredOuterHeight) ? measuredOuterHeight : windowTarget.height,
716
- );
717
- result.profileWindow = savedWindow?.window || null;
718
- if (wantsDevtools) {
719
- result.devtools = await requestDevtoolsOpen(profileId);
720
- }
721
- }
722
- }
723
- console.log(JSON.stringify(result, null, 2));
724
- }
725
-
726
- export async function handleStopCommand(args) {
727
- const rawTarget = String(args[1] || '').trim();
728
- const target = rawTarget.toLowerCase();
729
- const idTarget = readFlagValue(args, ['--id']);
730
- const aliasTarget = readFlagValue(args, ['--alias']);
731
- if (args.includes('--id') && !idTarget) {
732
- throw new Error('Usage: camo stop --id <instanceId>');
733
- }
734
- if (args.includes('--alias') && !aliasTarget) {
735
- throw new Error('Usage: camo stop --alias <alias>');
736
- }
737
- const stopIdle = target === 'idle' || args.includes('--idle');
738
- const stopAll = target === 'all';
739
- const serviceUp = await checkBrowserService();
740
- const resolvedSessions = await loadResolvedSessions(serviceUp);
741
-
742
- if (stopAll) {
743
- const profileSet = new Set(resolvedSessions.map((item) => String(item?.profileId || '').trim()).filter(Boolean));
744
- if (profileSet.size === 0) {
745
- for (const session of listRegisteredSessions()) {
746
- if (String(session?.status || '').trim() === 'closed') continue;
747
- const profileId = String(session?.profileId || '').trim();
748
- if (profileId) profileSet.add(profileId);
749
- }
750
- }
751
-
752
- const results = [];
753
- for (const profileId of profileSet) {
754
- // eslint-disable-next-line no-await-in-loop
755
- results.push(await stopAndCleanupProfile(profileId, { serviceUp }));
756
- }
757
- console.log(JSON.stringify({
758
- ok: true,
759
- mode: 'all',
760
- serviceUp,
761
- closed: results.filter((item) => item.ok).length,
762
- failed: results.filter((item) => !item.ok).length,
763
- results,
764
- }, null, 2));
765
- return;
766
- }
767
-
768
- if (stopIdle) {
769
- const now = Date.now();
770
- const registeredSessions = listRegisteredSessions();
771
- const regMap = new Map(
772
- registeredSessions
773
- .filter((item) => item && String(item?.status || '').trim() === 'active')
774
- .map((item) => [String(item.profileId || '').trim(), item]),
775
- );
776
- const idleTargets = new Set(
777
- registeredSessions
778
- .filter((item) => String(item?.status || '').trim() === 'active')
779
- .map((item) => ({ session: item, idle: computeIdleState(item, now) }))
780
- .filter((item) => item.idle.idle)
781
- .map((item) => item.session.profileId),
782
- );
783
- let orphanLiveHeadlessCount = 0;
784
- for (const live of resolvedSessions.filter((item) => item.live)) {
785
- const liveProfileId = String(live?.profileId || '').trim();
786
- if (!liveProfileId) continue;
787
- if (regMap.has(liveProfileId) || idleTargets.has(liveProfileId)) continue;
788
- const mode = String(live?.mode || '').toLowerCase();
789
- const liveHeadless = live?.headless === true || mode.includes('headless');
790
- // Live but unregistered headless sessions are treated as idle-orphan targets.
791
- if (liveHeadless) {
792
- idleTargets.add(liveProfileId);
793
- orphanLiveHeadlessCount += 1;
794
- }
795
- }
796
- const results = [];
797
- for (const profileId of idleTargets) {
798
- // eslint-disable-next-line no-await-in-loop
799
- results.push(await stopAndCleanupProfile(profileId, { serviceUp }));
800
- }
801
- console.log(JSON.stringify({
802
- ok: true,
803
- mode: 'idle',
804
- serviceUp,
805
- targetCount: idleTargets.size,
806
- orphanLiveHeadlessCount,
807
- closed: results.filter((item) => item.ok).length,
808
- failed: results.filter((item) => !item.ok).length,
809
- results,
810
- }, null, 2));
811
- return;
812
- }
813
-
814
- let profileId = null;
815
- let resolvedBy = 'profile';
816
- if (idTarget) {
817
- const resolved = resolveSessionTarget(idTarget);
818
- if (!resolved) throw new Error(`No session found for instance id: ${idTarget}`);
819
- profileId = resolved.profileId;
820
- resolvedBy = resolved.reason;
821
- } else if (aliasTarget) {
822
- const resolved = resolveSessionTarget(aliasTarget);
823
- if (!resolved) throw new Error(`No session found for alias: ${aliasTarget}`);
824
- profileId = resolved.profileId;
825
- resolvedBy = resolved.reason;
826
- } else {
827
- const positional = args.slice(1).find((arg) => arg && !String(arg).startsWith('--')) || null;
828
- if (positional) {
829
- const resolved = resolveSessionTarget(positional);
830
- if (resolved) {
831
- profileId = resolved.profileId;
832
- resolvedBy = resolved.reason;
833
- } else {
834
- profileId = positional;
835
- }
836
- }
837
- }
838
-
839
- if (!profileId) {
840
- profileId = getDefaultProfile();
841
- }
842
- if (!profileId) {
843
- throw new Error('Usage: camo stop [profileId] | camo stop --id <instanceId> | camo stop --alias <alias> | camo stop all | camo stop idle');
844
- }
845
-
846
- const result = await stopAndCleanupProfile(profileId, { serviceUp });
847
- if (!result.ok && serviceUp) {
848
- throw new Error(result.error || `stop failed for profile: ${profileId}`);
849
- }
850
- console.log(JSON.stringify({
851
- ok: true,
852
- profileId,
853
- resolvedBy,
854
- serviceUp,
855
- warning: (!serviceUp && !result.ok) ? result.error : null,
856
- result: result.result || null,
857
- }, null, 2));
858
- }
859
-
860
- export async function handleStatusCommand(args) {
861
- await ensureBrowserService();
862
- const profileId = args[1];
863
- if (profileId && args[0] === 'status') {
864
- const sessions = await getResolvedSessions();
865
- const session = sessions.find((item) => item.profileId === profileId) || null;
866
- console.log(JSON.stringify({ ok: true, session }, null, 2));
867
- return;
868
- }
869
- const sessions = await getResolvedSessions();
870
- console.log(JSON.stringify({ ok: true, sessions, count: sessions.length }, null, 2));
871
- }
872
-
873
- export async function handleGotoCommand(args) {
874
- await ensureBrowserService();
875
- const positionals = getPositionals(args);
876
- const profileSet = new Set(listProfiles());
877
-
878
- let profileId;
879
- let url;
880
-
881
- if (positionals.length === 1) {
882
- profileId = getDefaultProfile();
883
- url = positionals[0];
884
- } else {
885
- profileId = resolveProfileId(positionals, 0, getDefaultProfile);
886
- url = positionals[1];
887
- }
888
-
889
- if (!profileId) throw new Error('Usage: camo goto [profileId] <url> (or set default profile first)');
890
- if (!url) throw new Error('Usage: camo goto [profileId] <url>');
891
- assertExistingProfile(profileId, profileSet);
892
- const active = await getSessionByProfile(profileId);
893
- if (!active) {
894
- throw new Error(
895
- `No active session for profile: ${profileId}. Start via "camo start ${profileId}" (or UI CLI startup) before goto.`,
896
- );
897
- }
898
-
899
- const result = await callAPI('goto', { profileId, url: ensureUrlScheme(url) });
900
- updateSession(profileId, { url: ensureUrlScheme(url), lastSeen: Date.now() });
901
- console.log(JSON.stringify(result, null, 2));
902
- }
903
-
904
- export async function handleBackCommand(args) {
905
- await ensureBrowserService();
906
- const profileId = resolveProfileId(args, 1, getDefaultProfile);
907
- if (!profileId) throw new Error('Usage: camo back [profileId] (or set default profile first)');
908
- const result = await callAPI('page:back', { profileId });
909
- console.log(JSON.stringify(result, null, 2));
910
- }
911
-
912
- export async function handleScreenshotCommand(args) {
913
- await ensureBrowserService();
914
- const fullPage = args.includes('--full');
915
- const outputIdx = args.indexOf('--output');
916
- const output = outputIdx >= 0 ? args[outputIdx + 1] : null;
917
-
918
- let profileId = null;
919
- for (let i = 1; i < args.length; i++) {
920
- const arg = args[i];
921
- if (arg === '--full') continue;
922
- if (arg === '--output') { i++; continue; }
923
- if (arg.startsWith('--')) continue;
924
- profileId = arg;
925
- break;
926
- }
927
-
928
- if (!profileId) profileId = getDefaultProfile();
929
- if (!profileId) throw new Error('Usage: camo screenshot [profileId] [--output <file>] [--full]');
930
-
931
- const result = await callAPI('screenshot', { profileId, fullPage });
932
-
933
- if (output && result?.data) {
934
- fs.writeFileSync(output, Buffer.from(result.data, 'base64'));
935
- console.log(`Screenshot saved to ${output}`);
936
- return;
937
- }
938
-
939
- console.log(JSON.stringify(result, null, 2));
940
- }
941
-
942
- export async function handleScrollCommand(args) {
943
- await ensureBrowserService();
944
- const directionFlags = new Set(['--up', '--down', '--left', '--right']);
945
- const isFlag = (arg) => arg?.startsWith('--');
946
- const selectorIdx = args.indexOf('--selector');
947
- const selector = selectorIdx >= 0 ? String(args[selectorIdx + 1] || '').trim() : null;
948
- const highlightRequested = resolveHighlightEnabled(args);
949
- const highlight = highlightRequested;
950
-
951
- let profileId = null;
952
- for (let i = 1; i < args.length; i++) {
953
- const arg = args[i];
954
- if (directionFlags.has(arg)) continue;
955
- if (arg === '--amount') { i++; continue; }
956
- if (arg === '--selector') { i++; continue; }
957
- if (arg === '--highlight' || arg === '--no-highlight') continue;
958
- if (isFlag(arg)) continue;
959
- profileId = arg;
960
- break;
961
- }
962
- if (!profileId) profileId = getDefaultProfile();
963
- if (!profileId) throw new Error('Usage: camo scroll [profileId] [--down|--up|--left|--right] [--amount <px>] [--selector <css>] [--highlight|--no-highlight]');
964
- if (selectorIdx >= 0 && !selector) {
965
- throw new Error('Usage: camo scroll [profileId] --selector <css>');
966
- }
967
-
968
- const direction = args.includes('--up') ? 'up' : args.includes('--left') ? 'left' : args.includes('--right') ? 'right' : 'down';
969
- const amountIdx = args.indexOf('--amount');
970
- const amount = amountIdx >= 0 ? Number(args[amountIdx + 1]) || 300 : 300;
971
-
972
- const target = await callAPI('evaluate', {
973
- profileId,
974
- script: buildScrollTargetScript({ selector, highlight, requireVisibleContainer: Boolean(selector) }),
975
- }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
976
- const scrollTarget = target?.result || null;
977
- if (!scrollTarget?.ok || !scrollTarget?.center) {
978
- throw new Error(scrollTarget?.error || 'visible scroll container not found');
979
- }
980
- const deltaX = direction === 'left' ? -amount : direction === 'right' ? amount : 0;
981
- const deltaY = direction === 'up' ? -amount : direction === 'down' ? amount : 0;
982
- await callAPI('mouse:click', {
983
- profileId,
984
- x: scrollTarget.center.x,
985
- y: scrollTarget.center.y,
986
- button: 'left',
987
- clicks: 1,
988
- delay: 30,
989
- }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
990
- const result = await callAPI('mouse:wheel', {
991
- profileId,
992
- deltaX,
993
- deltaY,
994
- anchorX: scrollTarget.center.x,
995
- anchorY: scrollTarget.center.y,
996
- }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
997
- console.log(JSON.stringify({
998
- ...result,
999
- scrollTarget,
1000
- highlight,
1001
- }, null, 2));
1002
- }
1003
-
1004
- export async function handleClickCommand(args) {
1005
- await ensureBrowserService();
1006
- const positionals = getPositionals(args);
1007
- const highlight = resolveHighlightEnabled(args);
1008
- let profileId;
1009
- let selector;
1010
-
1011
- if (positionals.length === 1) {
1012
- profileId = getDefaultProfile();
1013
- selector = positionals[0];
1014
- } else {
1015
- profileId = positionals[0];
1016
- selector = positionals[1];
1017
- }
1018
-
1019
- if (!profileId) throw new Error('Usage: camo click [profileId] <selector> [--highlight|--no-highlight]');
1020
- if (!selector) throw new Error('Usage: camo click [profileId] <selector> [--highlight|--no-highlight]');
1021
-
1022
- let target = await resolveVisibleTargetPoint(profileId, selector, { highlight });
1023
- const ensured = await ensureClickTargetInViewport(profileId, selector, target, {
1024
- maxAutoScrollSteps: 3,
1025
- });
1026
- if (!ensured.targetFullyVisible) {
1027
- throw new Error(`Click target not fully visible after ${ensured.autoScrolled} auto-scroll attempts: ${selector}`);
1028
- }
1029
- target = ensured.target;
1030
- if (highlight) {
1031
- target = await resolveVisibleTargetPoint(profileId, selector, { highlight: true });
1032
- }
1033
- const result = await callAPI('mouse:click', {
1034
- profileId,
1035
- x: target.center.x,
1036
- y: target.center.y,
1037
- button: 'left',
1038
- clicks: 1,
1039
- }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
1040
- console.log(JSON.stringify({
1041
- ...result,
1042
- selector,
1043
- highlight,
1044
- autoScrolled: ensured.autoScrolled,
1045
- targetFullyVisible: ensured.targetFullyVisible,
1046
- target,
1047
- }, null, 2));
1048
- }
1049
-
1050
- export async function handleTypeCommand(args) {
1051
- await ensureBrowserService();
1052
- const positionals = getPositionals(args);
1053
- const highlight = resolveHighlightEnabled(args);
1054
- let profileId;
1055
- let selector;
1056
- let text;
1057
-
1058
- if (positionals.length === 2) {
1059
- profileId = getDefaultProfile();
1060
- selector = positionals[0];
1061
- text = positionals[1];
1062
- } else {
1063
- profileId = positionals[0];
1064
- selector = positionals[1];
1065
- text = positionals[2];
1066
- }
1067
-
1068
- if (!profileId) throw new Error('Usage: camo type [profileId] <selector> <text> [--highlight|--no-highlight]');
1069
- if (!selector || text === undefined) throw new Error('Usage: camo type [profileId] <selector> <text> [--highlight|--no-highlight]');
1070
-
1071
- const target = await resolveVisibleTargetPoint(profileId, selector, { highlight });
1072
- await callAPI('mouse:click', {
1073
- profileId,
1074
- x: target.center.x,
1075
- y: target.center.y,
1076
- button: 'left',
1077
- clicks: 1,
1078
- }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
1079
- await callAPI('keyboard:press', {
1080
- profileId,
1081
- key: process.platform === 'darwin' ? 'Meta+A' : 'Control+A',
1082
- }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
1083
- await callAPI('keyboard:press', { profileId, key: 'Backspace' }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
1084
- const result = await callAPI('keyboard:type', {
1085
- profileId,
1086
- text: String(text),
1087
- }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
1088
- console.log(JSON.stringify({
1089
- ...result,
1090
- selector,
1091
- typed: String(text).length,
1092
- highlight,
1093
- target,
1094
- }, null, 2));
1095
- }
1096
-
1097
- export async function handleHighlightCommand(args) {
1098
- await ensureBrowserService();
1099
- const positionals = getPositionals(args);
1100
- let profileId;
1101
- let selector;
1102
-
1103
- if (positionals.length === 1) {
1104
- profileId = getDefaultProfile();
1105
- selector = positionals[0];
1106
- } else {
1107
- profileId = positionals[0];
1108
- selector = positionals[1];
1109
- }
1110
-
1111
- if (!profileId) throw new Error('Usage: camo highlight [profileId] <selector>');
1112
- if (!selector) throw new Error('Usage: camo highlight [profileId] <selector>');
1113
-
1114
- const result = await callAPI('browser:highlight', {
1115
- profile: profileId,
1116
- profileId,
1117
- selector,
1118
- });
1119
- console.log(JSON.stringify(result, null, 2));
1120
- }
1121
-
1122
- export async function handleClearHighlightCommand(args) {
1123
- await ensureBrowserService();
1124
- const profileId = resolveProfileId(args, 1, getDefaultProfile);
1125
- if (!profileId) throw new Error('Usage: camo clear-highlight [profileId]');
1126
-
1127
- const result = await callAPI('browser:clear-highlight', {
1128
- profile: profileId,
1129
- profileId,
1130
- });
1131
- console.log(JSON.stringify(result, null, 2));
1132
- }
1133
-
1134
- export async function handleViewportCommand(args) {
1135
- await ensureBrowserService();
1136
- const profileId = resolveProfileId(args, 1, getDefaultProfile);
1137
- if (!profileId) throw new Error('Usage: camo viewport [profileId] --width <w> --height <h>');
1138
-
1139
- const widthIdx = args.indexOf('--width');
1140
- const heightIdx = args.indexOf('--height');
1141
- const width = widthIdx >= 0 ? Number(args[widthIdx + 1]) : 1280;
1142
- const height = heightIdx >= 0 ? Number(args[heightIdx + 1]) : 800;
1143
-
1144
- if (!Number.isFinite(width) || !Number.isFinite(height)) {
1145
- throw new Error('Usage: camo viewport [profileId] --width <w> --height <h>');
1146
- }
1147
-
1148
- const result = await callAPI('page:setViewport', { profileId, width, height });
1149
- console.log(JSON.stringify(result, null, 2));
1150
- }
1151
-
1152
- export async function handleNewPageCommand(args) {
1153
- await ensureBrowserService();
1154
- const profileId = resolveProfileId(args, 1, getDefaultProfile);
1155
- if (!profileId) throw new Error('Usage: camo new-page [profileId] [--url <url>] (or set default profile first)');
1156
- const urlIdx = args.indexOf('--url');
1157
- const url = urlIdx >= 0 ? args[urlIdx + 1] : undefined;
1158
- const result = await callAPI('newPage', { profileId, ...(url ? { url: ensureUrlScheme(url) } : {}) });
1159
- console.log(JSON.stringify(result, null, 2));
1160
- }
1161
-
1162
- export async function handleClosePageCommand(args) {
1163
- await ensureBrowserService();
1164
- const profileId = resolveProfileId(args, 1, getDefaultProfile);
1165
- if (!profileId) throw new Error('Usage: camo close-page [profileId] [index] (or set default profile first)');
1166
-
1167
- let index;
1168
- for (let i = args.length - 1; i >= 1; i--) {
1169
- const arg = args[i];
1170
- if (arg.startsWith('--')) continue;
1171
- const num = Number(arg);
1172
- if (Number.isFinite(num)) { index = num; break; }
1173
- }
1174
-
1175
- const result = await callAPI('page:close', { profileId, ...(Number.isFinite(index) ? { index } : {}) });
1176
- console.log(JSON.stringify(result, null, 2));
1177
- }
1178
-
1179
- export async function handleSwitchPageCommand(args) {
1180
- await ensureBrowserService();
1181
- const profileId = resolveProfileId(args, 1, getDefaultProfile);
1182
- if (!profileId) throw new Error('Usage: camo switch-page [profileId] <index> (or set default profile first)');
1183
-
1184
- let index;
1185
- for (let i = args.length - 1; i >= 1; i--) {
1186
- const arg = args[i];
1187
- if (arg.startsWith('--')) continue;
1188
- const num = Number(arg);
1189
- if (Number.isFinite(num)) { index = num; break; }
1190
- }
1191
-
1192
- if (!Number.isFinite(index)) throw new Error('Usage: camo switch-page [profileId] <index>');
1193
- const result = await callAPI('page:switch', { profileId, index });
1194
- console.log(JSON.stringify(result, null, 2));
1195
- }
1196
-
1197
- export async function handleListPagesCommand(args) {
1198
- await ensureBrowserService();
1199
- const profileId = resolveProfileId(args, 1, getDefaultProfile);
1200
- if (!profileId) throw new Error('Usage: camo list-pages [profileId] (or set default profile first)');
1201
- const sessions = await getResolvedSessions();
1202
- const session = sessions.find((item) => item.profileId === profileId) || null;
1203
- if (!session?.live) {
1204
- throw new Error(`Profile session not live: ${profileId}. Start via "camo start ${profileId}" first.`);
1205
- }
1206
- const result = await callAPI('page:list', { profileId });
1207
- console.log(JSON.stringify(result, null, 2));
1208
- }
1209
-
1210
- export async function handleShutdownCommand() {
1211
- await ensureBrowserService();
1212
-
1213
- // Get all active sessions
1214
- const status = await callAPI('getStatus', {});
1215
- const sessions = Array.isArray(status?.sessions) ? status.sessions : [];
1216
-
1217
- // Stop each session and cleanup registry
1218
- for (const session of sessions) {
1219
- try {
1220
- await callAPI('stop', { profileId: session.profileId });
1221
- } catch {
1222
- // Best effort cleanup
1223
- }
1224
- stopSessionWatchdog(session.profileId);
1225
- releaseLock(session.profileId);
1226
- markSessionClosed(session.profileId);
1227
- }
1228
-
1229
- // Cleanup any remaining registry entries
1230
- const registered = listRegisteredSessions();
1231
- for (const reg of registered) {
1232
- if (reg.status !== 'closed') {
1233
- stopSessionWatchdog(reg.profileId);
1234
- markSessionClosed(reg.profileId);
1235
- releaseLock(reg.profileId);
1236
- }
1237
- }
1238
- stopAllSessionWatchdogs();
1239
-
1240
- const result = await callAPI('service:shutdown', {});
1241
- console.log(JSON.stringify(result, null, 2));
1242
- }
1243
-
1244
- export async function handleSessionsCommand(args) {
1245
- const serviceUp = await checkBrowserService();
1246
- const merged = await loadResolvedSessions(serviceUp);
1247
- const registeredSessions = listRegisteredSessions();
1248
-
1249
- console.log(JSON.stringify({
1250
- ok: true,
1251
- serviceUp,
1252
- sessions: merged,
1253
- count: merged.length,
1254
- registered: registeredSessions.length,
1255
- live: liveSessions.length,
1256
- }, null, 2));
1257
- }
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {
4
+ listProfiles,
5
+ getDefaultProfile,
6
+ getHighlightMode,
7
+ getProfileWindowSize,
8
+ setProfileWindowSize,
9
+ } from '../utils/config.mjs';
10
+ import { callAPI, ensureCamoufox, ensureBrowserService, getSessionByProfile, checkBrowserService, getResolvedSessions } from '../utils/browser-service.mjs';
11
+ import { resolveProfileId, ensureUrlScheme, looksLikeUrlToken, getPositionals } from '../utils/args.mjs';
12
+ import { ensureJsExecutionEnabled } from '../utils/js-policy.mjs';
13
+ import { acquireLock, releaseLock, cleanupStaleLocks } from '../lifecycle/lock.mjs';
14
+ import {
15
+ buildScrollTargetScript,
16
+ } from '../container/runtime-core/operations/selector-scripts.mjs';
17
+ import {
18
+ registerSession,
19
+ updateSession,
20
+ getSessionInfo,
21
+ unregisterSession,
22
+ listRegisteredSessions,
23
+ markSessionClosed,
24
+ cleanupStaleSessions,
25
+ resolveSessionTarget,
26
+ isSessionAliasTaken,
27
+ } from '../lifecycle/session-registry.mjs';
28
+ import { startSessionWatchdog, stopAllSessionWatchdogs, stopSessionWatchdog } from '../lifecycle/session-watchdog.mjs';
29
+
30
+ const START_WINDOW_MIN_WIDTH = 960;
31
+ const START_WINDOW_MIN_HEIGHT = 700;
32
+ const START_WINDOW_MAX_RESERVE = 240;
33
+ const START_WINDOW_DEFAULT_RESERVE = 0;
34
+ const DEFAULT_HEADLESS_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
35
+ const DEVTOOLS_SHORTCUTS = process.platform === 'darwin'
36
+ ? ['Meta+Alt+I', 'F12']
37
+ : ['F12', 'Control+Shift+I'];
38
+ const INPUT_ACTION_TIMEOUT_MS = Math.max(
39
+ 1000,
40
+ parseNumber(process.env.CAMO_INPUT_ACTION_TIMEOUT_MS, 30000),
41
+ );
42
+
43
+ function sleep(ms) {
44
+ return new Promise((resolve) => setTimeout(resolve, ms));
45
+ }
46
+
47
+ function parseNumber(value, fallback = 0) {
48
+ const parsed = Number(value);
49
+ return Number.isFinite(parsed) ? parsed : fallback;
50
+ }
51
+
52
+ function clamp(value, min, max) {
53
+ return Math.min(Math.max(value, min), max);
54
+ }
55
+
56
+ function readFlagValue(args, names) {
57
+ for (let i = 0; i < args.length; i += 1) {
58
+ if (!names.includes(args[i])) continue;
59
+ const value = args[i + 1];
60
+ if (!value || String(value).startsWith('-')) return null;
61
+ return value;
62
+ }
63
+ return null;
64
+ }
65
+
66
+ function parseDurationMs(raw, fallbackMs) {
67
+ if (raw === undefined || raw === null || String(raw).trim() === '') return fallbackMs;
68
+ const text = String(raw).trim().toLowerCase();
69
+ if (text === '0' || text === 'off' || text === 'none' || text === 'disable' || text === 'disabled') return 0;
70
+ const matched = text.match(/^(\d+(?:\.\d+)?)(ms|s|m|h)?$/);
71
+ if (!matched) {
72
+ throw new Error('Invalid --idle-timeout. Use forms like 30m, 1800s, 5000ms, 1h, 0');
73
+ }
74
+ const value = Number(matched[1]);
75
+ if (!Number.isFinite(value) || value < 0) {
76
+ throw new Error('Invalid --idle-timeout value');
77
+ }
78
+ const unit = matched[2] || 'm';
79
+ const factor = unit === 'h' ? 3600000 : unit === 'm' ? 60000 : unit === 's' ? 1000 : 1;
80
+ return Math.floor(value * factor);
81
+ }
82
+
83
+ function assertExistingProfile(profileId, profileSet = null) {
84
+ const id = String(profileId || '').trim();
85
+ if (!id) throw new Error('profileId is required');
86
+ const known = profileSet || new Set(listProfiles());
87
+ if (!known.has(id)) {
88
+ throw new Error(`profile not found: ${id}. create it first with "camo profile create ${id}"`);
89
+ }
90
+ return id;
91
+ }
92
+
93
+ async function resolveVisibleTargetPoint(profileId, selector, options = {}) {
94
+ const selectorLiteral = JSON.stringify(String(selector || '').trim());
95
+ const highlight = options.highlight === true;
96
+ const payload = await callAPI('evaluate', {
97
+ profileId,
98
+ script: `(() => {
99
+ const selector = ${selectorLiteral};
100
+ const highlight = ${highlight ? 'true' : 'false'};
101
+ const nodes = Array.from(document.querySelectorAll(selector));
102
+ const isVisible = (node) => {
103
+ if (!(node instanceof Element)) return false;
104
+ const rect = node.getBoundingClientRect?.();
105
+ if (!rect || rect.width <= 0 || rect.height <= 0) return false;
106
+ try {
107
+ const style = window.getComputedStyle(node);
108
+ if (!style) return false;
109
+ if (style.display === 'none') return false;
110
+ if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
111
+ const opacity = Number.parseFloat(String(style.opacity || '1'));
112
+ if (Number.isFinite(opacity) && opacity <= 0.01) return false;
113
+ } catch {
114
+ return false;
115
+ }
116
+ return true;
117
+ };
118
+ const hitVisible = (node) => {
119
+ if (!(node instanceof Element)) return false;
120
+ const rect = node.getBoundingClientRect?.();
121
+ if (!rect) return false;
122
+ const x = Math.max(0, Math.min((window.innerWidth || 1) - 1, Math.round(rect.left + rect.width / 2)));
123
+ const y = Math.max(0, Math.min((window.innerHeight || 1) - 1, Math.round(rect.top + rect.height / 2)));
124
+ const top = document.elementFromPoint(x, y);
125
+ if (!top) return false;
126
+ return top === node || node.contains(top) || top.contains(node);
127
+ };
128
+ const target = nodes.find((item) => isVisible(item) && hitVisible(item))
129
+ || nodes.find((item) => isVisible(item))
130
+ || nodes[0]
131
+ || null;
132
+ if (!target) {
133
+ return { ok: false, error: 'selector_not_found', selector };
134
+ }
135
+ const rect = target.getBoundingClientRect?.() || { left: 0, top: 0, width: 1, height: 1 };
136
+ const center = {
137
+ x: Math.max(1, Math.min((window.innerWidth || 1) - 1, Math.round(rect.left + Math.max(1, rect.width / 2)))),
138
+ y: Math.max(1, Math.min((window.innerHeight || 1) - 1, Math.round(rect.top + Math.max(1, rect.height / 2)))),
139
+ };
140
+ if (highlight) {
141
+ try {
142
+ const id = 'camo-action-highlight-overlay';
143
+ const old = document.getElementById(id);
144
+ if (old) old.remove();
145
+ const overlay = document.createElement('div');
146
+ overlay.id = id;
147
+ overlay.style.position = 'fixed';
148
+ overlay.style.left = rect.left + 'px';
149
+ overlay.style.top = rect.top + 'px';
150
+ overlay.style.width = rect.width + 'px';
151
+ overlay.style.height = rect.height + 'px';
152
+ overlay.style.border = '2px solid #00A8FF';
153
+ overlay.style.borderRadius = '8px';
154
+ overlay.style.background = 'rgba(0,168,255,0.12)';
155
+ overlay.style.pointerEvents = 'none';
156
+ overlay.style.zIndex = '2147483647';
157
+ overlay.style.transition = 'opacity 120ms ease';
158
+ overlay.style.opacity = '1';
159
+ document.documentElement.appendChild(overlay);
160
+ setTimeout(() => {
161
+ overlay.style.opacity = '0';
162
+ setTimeout(() => overlay.remove(), 180);
163
+ }, 260);
164
+ } catch {}
165
+ }
166
+ return {
167
+ ok: true,
168
+ selector,
169
+ center,
170
+ rect: {
171
+ left: rect.left,
172
+ top: rect.top,
173
+ width: rect.width,
174
+ height: rect.height,
175
+ },
176
+ viewport: {
177
+ width: Number(window.innerWidth || 0),
178
+ height: Number(window.innerHeight || 0),
179
+ },
180
+ };
181
+ })()`,
182
+ }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
183
+ const result = payload?.result || payload?.data?.result || payload?.data || payload || null;
184
+ if (!result || result.ok !== true || !result.center) {
185
+ throw new Error(`Element not found: ${selector}`);
186
+ }
187
+ return result;
188
+ }
189
+
190
+ function isTargetFullyInViewport(target, margin = 6) {
191
+ const rect = target?.rect && typeof target.rect === 'object' ? target.rect : null;
192
+ const viewport = target?.viewport && typeof target.viewport === 'object' ? target.viewport : null;
193
+ if (!rect || !viewport) return true;
194
+ const vw = Number(viewport.width || 0);
195
+ const vh = Number(viewport.height || 0);
196
+ if (!Number.isFinite(vw) || !Number.isFinite(vh) || vw <= 0 || vh <= 0) return true;
197
+ const left = Number(rect.left || 0);
198
+ const top = Number(rect.top || 0);
199
+ const width = Math.max(0, Number(rect.width || 0));
200
+ const height = Math.max(0, Number(rect.height || 0));
201
+ const right = left + width;
202
+ const bottom = top + height;
203
+ const m = Math.max(0, Number(margin) || 0);
204
+ return left >= m && top >= m && right <= (vw - m) && bottom <= (vh - m);
205
+ }
206
+
207
+ async function ensureClickTargetInViewport(profileId, selector, initialTarget, options = {}) {
208
+ let target = initialTarget;
209
+ const maxSteps = Math.max(0, Number(options.maxAutoScrollSteps ?? 8) || 8);
210
+ const settleMs = Math.max(0, Number(options.autoScrollSettleMs ?? 140) || 140);
211
+ let autoScrolled = 0;
212
+
213
+ while (autoScrolled < maxSteps && !isTargetFullyInViewport(target)) {
214
+ const rect = target?.rect && typeof target.rect === 'object' ? target.rect : {};
215
+ const viewport = target?.viewport && typeof target.viewport === 'object' ? target.viewport : {};
216
+ const vw = Math.max(1, Number(viewport.width || 1));
217
+ const vh = Math.max(1, Number(viewport.height || 1));
218
+ const rawCenterY = Number(rect.top || 0) + Math.max(1, Number(rect.height || 0)) / 2;
219
+ const desiredCenterY = clamp(Math.round(vh * 0.45), 80, Math.max(80, vh - 80));
220
+ let deltaY = Math.round(rawCenterY - desiredCenterY);
221
+ deltaY = clamp(deltaY, -900, 900);
222
+ if (Math.abs(deltaY) < 100) {
223
+ deltaY = deltaY >= 0 ? 120 : -120;
224
+ }
225
+ await callAPI('mouse:wheel', { profileId, deltaX: 0, deltaY }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
226
+ autoScrolled += 1;
227
+ if (settleMs > 0) {
228
+ await sleep(settleMs);
229
+ }
230
+ target = await resolveVisibleTargetPoint(profileId, selector, { highlight: false });
231
+ }
232
+
233
+ return {
234
+ target,
235
+ autoScrolled,
236
+ targetFullyVisible: isTargetFullyInViewport(target),
237
+ };
238
+ }
239
+
240
+ function validateAlias(alias) {
241
+ const text = String(alias || '').trim();
242
+ if (!text) return null;
243
+ if (!/^[a-zA-Z0-9._-]+$/.test(text)) {
244
+ throw new Error('Invalid --alias. Use only letters, numbers, dot, underscore, dash.');
245
+ }
246
+ return text.slice(0, 64);
247
+ }
248
+
249
+ function resolveHighlightEnabled(args) {
250
+ if (args.includes('--highlight')) return true;
251
+ if (args.includes('--no-highlight')) return false;
252
+ return getHighlightMode();
253
+ }
254
+
255
+ function formatDurationMs(ms) {
256
+ const value = Number(ms);
257
+ if (!Number.isFinite(value) || value <= 0) return 'disabled';
258
+ if (value % 3600000 === 0) return `${value / 3600000}h`;
259
+ if (value % 60000 === 0) return `${value / 60000}m`;
260
+ if (value % 1000 === 0) return `${value / 1000}s`;
261
+ return `${value}ms`;
262
+ }
263
+
264
+ function computeIdleState(session, now = Date.now()) {
265
+ const headless = session?.headless === true;
266
+ const timeoutMs = headless
267
+ ? (Number.isFinite(Number(session?.idleTimeoutMs)) ? Math.max(0, Number(session.idleTimeoutMs)) : DEFAULT_HEADLESS_IDLE_TIMEOUT_MS)
268
+ : 0;
269
+ const lastAt = Number(session?.lastActivityAt || session?.lastSeen || session?.startTime || now);
270
+ const idleMs = Math.max(0, now - (Number.isFinite(lastAt) ? lastAt : now));
271
+ const idle = headless && timeoutMs > 0 && idleMs >= timeoutMs;
272
+ return { headless, timeoutMs, idleMs, idle };
273
+ }
274
+
275
+ async function stopAndCleanupProfile(profileId, options = {}) {
276
+ const id = String(profileId || '').trim();
277
+ if (!id) return { profileId: id, ok: false, error: 'profile_required' };
278
+ const force = options.force === true;
279
+ const serviceUp = options.serviceUp === true;
280
+ let result = null;
281
+ let error = null;
282
+ if (serviceUp) {
283
+ try {
284
+ result = await callAPI('stop', force ? { profileId: id, force: true } : { profileId: id });
285
+ } catch (err) {
286
+ error = err;
287
+ }
288
+ }
289
+ stopSessionWatchdog(id);
290
+ releaseLock(id);
291
+ markSessionClosed(id);
292
+ return {
293
+ profileId: id,
294
+ ok: !error,
295
+ serviceUp,
296
+ result,
297
+ error: error ? (error.message || String(error)) : null,
298
+ };
299
+ }
300
+
301
+ async function loadResolvedSessions(serviceUp) {
302
+ if (!serviceUp) return [];
303
+ try {
304
+ return await getResolvedSessions();
305
+ } catch {
306
+ return [];
307
+ }
308
+ }
309
+
310
+ async function probeViewportSize(profileId) {
311
+ try {
312
+ const payload = await callAPI('evaluate', {
313
+ profileId,
314
+ script: '(() => ({ width: Number(window.innerWidth || 0), height: Number(window.innerHeight || 0) }))()',
315
+ });
316
+ const size = payload?.result || payload?.data || payload || {};
317
+ const width = Number(size?.width || 0);
318
+ const height = Number(size?.height || 0);
319
+ if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
320
+ return { width, height };
321
+ } catch {
322
+ return null;
323
+ }
324
+ }
325
+
326
+ export async function requestDevtoolsOpen(profileId, options = {}) {
327
+ const id = String(profileId || '').trim();
328
+ if (!id) {
329
+ return { ok: false, requested: false, reason: 'profile_required', shortcuts: [] };
330
+ }
331
+
332
+ const shortcuts = Array.isArray(options.shortcuts) && options.shortcuts.length
333
+ ? options.shortcuts.map((item) => String(item || '').trim()).filter(Boolean)
334
+ : DEVTOOLS_SHORTCUTS;
335
+ const settleMs = Math.max(0, parseNumber(options.settleMs, 180));
336
+ const before = await probeViewportSize(id);
337
+ const attempts = [];
338
+
339
+ for (const key of shortcuts) {
340
+ try {
341
+ await callAPI('keyboard:press', { profileId: id, key });
342
+ attempts.push({ key, ok: true });
343
+ if (settleMs > 0) {
344
+ // Allow browser UI animation to settle after shortcut.
345
+ // eslint-disable-next-line no-await-in-loop
346
+ await sleep(settleMs);
347
+ }
348
+ } catch (err) {
349
+ attempts.push({ key, ok: false, error: err?.message || String(err) });
350
+ }
351
+ }
352
+
353
+ const after = await probeViewportSize(id);
354
+ const beforeHeight = Number(before?.height || 0);
355
+ const afterHeight = Number(after?.height || 0);
356
+ const viewportReduced = beforeHeight > 0 && afterHeight > 0 && afterHeight < (beforeHeight - 100);
357
+ const successCount = attempts.filter((item) => item.ok).length;
358
+
359
+ return {
360
+ ok: successCount > 0,
361
+ requested: true,
362
+ shortcuts,
363
+ attempts,
364
+ before,
365
+ after,
366
+ verified: viewportReduced,
367
+ verification: viewportReduced ? 'viewport_height_reduced' : 'shortcut_dispatched',
368
+ };
369
+ }
370
+
371
+ export function computeTargetViewportFromWindowMetrics(measured) {
372
+ const innerWidth = Math.max(320, parseNumber(measured?.innerWidth, 0));
373
+ const innerHeight = Math.max(240, parseNumber(measured?.innerHeight, 0));
374
+ const outerWidth = Math.max(320, parseNumber(measured?.outerWidth, innerWidth));
375
+ const outerHeight = Math.max(240, parseNumber(measured?.outerHeight, innerHeight));
376
+
377
+ const rawDeltaW = Math.max(0, outerWidth - innerWidth);
378
+ const rawDeltaH = Math.max(0, outerHeight - innerHeight);
379
+ const frameW = rawDeltaW > 400 ? 16 : Math.min(rawDeltaW, 120);
380
+ const frameH = rawDeltaH > 400 ? 88 : Math.min(rawDeltaH, 180);
381
+
382
+ return {
383
+ width: Math.max(320, outerWidth - frameW),
384
+ height: Math.max(240, outerHeight - frameH),
385
+ frameW,
386
+ frameH,
387
+ innerWidth,
388
+ innerHeight,
389
+ outerWidth,
390
+ outerHeight,
391
+ };
392
+ }
393
+
394
+ export function computeStartWindowSize(metrics, options = {}) {
395
+ const display = metrics?.metrics || metrics || {};
396
+ const reserveFromEnv = parseNumber(process.env.CAMO_DEFAULT_WINDOW_VERTICAL_RESERVE, START_WINDOW_DEFAULT_RESERVE);
397
+ const reserve = clamp(
398
+ parseNumber(options.reservePx, reserveFromEnv),
399
+ 0,
400
+ START_WINDOW_MAX_RESERVE,
401
+ );
402
+
403
+ const workWidth = parseNumber(display.workWidth, 0);
404
+ const workHeight = parseNumber(display.workHeight, 0);
405
+ const width = parseNumber(display.width, 0);
406
+ const height = parseNumber(display.height, 0);
407
+ const baseW = Math.floor(workWidth > 0 ? workWidth : width);
408
+ const baseH = Math.floor(workHeight > 0 ? workHeight : height);
409
+
410
+ if (baseW <= 0 || baseH <= 0) {
411
+ return {
412
+ width: 1920,
413
+ height: 1000,
414
+ reservePx: reserve,
415
+ source: 'fallback',
416
+ };
417
+ }
418
+
419
+ return {
420
+ width: Math.max(START_WINDOW_MIN_WIDTH, baseW),
421
+ height: Math.max(START_WINDOW_MIN_HEIGHT, baseH - reserve),
422
+ reservePx: reserve,
423
+ source: workWidth > 0 || workHeight > 0 ? 'workArea' : 'screen',
424
+ };
425
+ }
426
+
427
+ async function probeWindowMetrics(profileId) {
428
+ const measured = await callAPI('evaluate', {
429
+ profileId,
430
+ script: '({ innerWidth: window.innerWidth, innerHeight: window.innerHeight, outerWidth: window.outerWidth, outerHeight: window.outerHeight })',
431
+ });
432
+ return measured?.result || {};
433
+ }
434
+
435
+ export async function syncWindowViewportAfterResize(profileId, width, height, options = {}) {
436
+ const settleMs = Math.max(40, parseNumber(options.settleMs, 120));
437
+ const attempts = Math.max(1, Math.min(8, Math.floor(parseNumber(options.attempts, 4))));
438
+ const tolerancePx = Math.max(0, parseNumber(options.tolerancePx, 3));
439
+
440
+ const windowResult = await callAPI('window:resize', { profileId, width, height });
441
+ await sleep(settleMs);
442
+
443
+ let measured = {};
444
+ let verified = {};
445
+ let viewport = null;
446
+ let matched = false;
447
+ let target = { width: 1280, height: 720, frameW: 16, frameH: 88 };
448
+
449
+ for (let i = 0; i < attempts; i += 1) {
450
+ measured = await probeWindowMetrics(profileId);
451
+ target = computeTargetViewportFromWindowMetrics(measured);
452
+ viewport = await callAPI('page:setViewport', {
453
+ profileId,
454
+ width: target.width,
455
+ height: target.height,
456
+ });
457
+ await sleep(settleMs);
458
+ verified = await probeWindowMetrics(profileId);
459
+ const dw = Math.abs(parseNumber(verified?.innerWidth, 0) - target.width);
460
+ const dh = Math.abs(parseNumber(verified?.innerHeight, 0) - target.height);
461
+ if (dw <= tolerancePx && dh <= tolerancePx) {
462
+ matched = true;
463
+ break;
464
+ }
465
+ }
466
+
467
+ return {
468
+ window: windowResult,
469
+ measured,
470
+ verified,
471
+ targetViewport: {
472
+ width: target.width,
473
+ height: target.height,
474
+ frameW: target.frameW,
475
+ frameH: target.frameH,
476
+ matched,
477
+ },
478
+ viewport,
479
+ };
480
+ }
481
+
482
+ export async function handleStartCommand(args) {
483
+ ensureCamoufox();
484
+ await ensureBrowserService();
485
+ cleanupStaleLocks();
486
+ cleanupStaleSessions();
487
+
488
+ const urlIdx = args.indexOf('--url');
489
+ const explicitUrl = urlIdx >= 0 ? args[urlIdx + 1] : undefined;
490
+ const widthIdx = args.indexOf('--width');
491
+ const heightIdx = args.indexOf('--height');
492
+ const explicitWidth = widthIdx >= 0 ? parseNumber(args[widthIdx + 1], NaN) : NaN;
493
+ const explicitHeight = heightIdx >= 0 ? parseNumber(args[heightIdx + 1], NaN) : NaN;
494
+ const hasExplicitWidth = Number.isFinite(explicitWidth);
495
+ const hasExplicitHeight = Number.isFinite(explicitHeight);
496
+ const alias = validateAlias(readFlagValue(args, ['--alias']));
497
+ const idleTimeoutRaw = readFlagValue(args, ['--idle-timeout']);
498
+ const parsedIdleTimeoutMs = parseDurationMs(idleTimeoutRaw, DEFAULT_HEADLESS_IDLE_TIMEOUT_MS);
499
+ const wantsDevtools = args.includes('--devtools');
500
+ const wantsRecord = args.includes('--record');
501
+ const recordName = readFlagValue(args, ['--record-name']);
502
+ const recordOutputRaw = readFlagValue(args, ['--record-output']);
503
+ const recordOverlay = args.includes('--no-record-overlay')
504
+ ? false
505
+ : args.includes('--record-overlay')
506
+ ? true
507
+ : null;
508
+ if (hasExplicitWidth !== hasExplicitHeight) {
509
+ throw new Error('Usage: camo start [profileId] [--url <url>] [--no-headless|--visible] [--devtools] [--record] [--record-name <name>] [--record-output <path>] [--record-overlay|--no-record-overlay] [--alias <name>] [--idle-timeout <duration>] [--width <w> --height <h>]');
510
+ }
511
+ if ((hasExplicitWidth && explicitWidth < START_WINDOW_MIN_WIDTH) || (hasExplicitHeight && explicitHeight < START_WINDOW_MIN_HEIGHT)) {
512
+ throw new Error(`Window size too small. Minimum is ${START_WINDOW_MIN_WIDTH}x${START_WINDOW_MIN_HEIGHT}`);
513
+ }
514
+ if (args.includes('--record-name') && !recordName) {
515
+ throw new Error('Usage: camo start [profileId] --record-name <name>');
516
+ }
517
+ if (args.includes('--record-output') && !recordOutputRaw) {
518
+ throw new Error('Usage: camo start [profileId] --record-output <path>');
519
+ }
520
+ const recordOutput = recordOutputRaw ? path.resolve(recordOutputRaw) : null;
521
+ const hasExplicitWindowSize = hasExplicitWidth && hasExplicitHeight;
522
+ const profileSet = new Set(listProfiles());
523
+ let implicitUrl;
524
+
525
+ let profileId = null;
526
+ for (let i = 1; i < args.length; i++) {
527
+ const arg = args[i];
528
+ if (arg === '--url') { i++; continue; }
529
+ if (arg === '--width' || arg === '--height') { i++; continue; }
530
+ if (arg === '--alias' || arg === '--idle-timeout' || arg === '--record-name' || arg === '--record-output') { i++; continue; }
531
+ if (arg === '--headless' || arg === '--no-headless' || arg === '--visible') continue;
532
+ if (arg === '--record' || arg === '--record-overlay' || arg === '--no-record-overlay') continue;
533
+ if (arg.startsWith('--')) continue;
534
+
535
+ if (looksLikeUrlToken(arg) && !profileSet.has(arg)) {
536
+ implicitUrl = arg;
537
+ continue;
538
+ }
539
+
540
+ profileId = arg;
541
+ break;
542
+ }
543
+
544
+ if (!profileId) {
545
+ profileId = getDefaultProfile();
546
+ if (!profileId) {
547
+ throw new Error('No default profile set. Run: camo profile default <profileId>');
548
+ }
549
+ }
550
+ assertExistingProfile(profileId, profileSet);
551
+ if (alias && isSessionAliasTaken(alias, profileId)) {
552
+ throw new Error(`Alias is already in use: ${alias}`);
553
+ }
554
+
555
+ // Check for existing session in browser service
556
+ const existing = await getSessionByProfile(profileId);
557
+ if (existing) {
558
+ // Session exists in browser service - update registry and lock
559
+ acquireLock(profileId, { sessionId: existing.session_id || existing.profileId });
560
+ const saved = getSessionInfo(profileId);
561
+ const record = saved
562
+ ? updateSession(profileId, {
563
+ sessionId: existing.session_id || existing.profileId,
564
+ url: existing.current_url,
565
+ mode: existing.mode,
566
+ alias: alias || saved.alias || null,
567
+ })
568
+ : registerSession(profileId, {
569
+ sessionId: existing.session_id || existing.profileId,
570
+ url: existing.current_url,
571
+ mode: existing.mode,
572
+ alias: alias || null,
573
+ });
574
+ const idleState = computeIdleState(record);
575
+ const payload = {
576
+ ok: true,
577
+ sessionId: existing.session_id || existing.profileId,
578
+ instanceId: record.instanceId,
579
+ profileId,
580
+ message: 'Session already running',
581
+ url: existing.current_url,
582
+ alias: record.alias || null,
583
+ idleTimeoutMs: idleState.timeoutMs,
584
+ idleTimeout: formatDurationMs(idleState.timeoutMs),
585
+ closeHint: {
586
+ byProfile: `camo stop ${profileId}`,
587
+ byId: `camo stop --id ${record.instanceId}`,
588
+ byAlias: record.alias ? `camo stop --alias ${record.alias}` : null,
589
+ },
590
+ };
591
+ const existingMode = String(existing?.mode || record?.mode || '').toLowerCase();
592
+ const existingHeadless = existing?.headless === true || existingMode.includes('headless');
593
+ if (!existingHeadless && wantsDevtools) {
594
+ payload.devtools = await requestDevtoolsOpen(profileId);
595
+ }
596
+ if (wantsRecord) {
597
+ payload.recording = await callAPI('record:start', {
598
+ profileId,
599
+ ...(recordName ? { name: recordName } : {}),
600
+ ...(recordOutput ? { outputPath: recordOutput } : {}),
601
+ ...(recordOverlay !== null ? { overlay: recordOverlay } : {}),
602
+ });
603
+ }
604
+ console.log(JSON.stringify(payload, null, 2));
605
+ startSessionWatchdog(profileId);
606
+ return;
607
+ }
608
+
609
+ // No session in browser service - check registry for recovery
610
+ const registryInfo = getSessionInfo(profileId);
611
+ if (registryInfo && registryInfo.status === 'active') {
612
+ // Session was active but browser service doesn't have it
613
+ // This means service was restarted - clean up and start fresh
614
+ unregisterSession(profileId);
615
+ releaseLock(profileId);
616
+ }
617
+
618
+ const headless = !args.includes('--no-headless') && !args.includes('--visible');
619
+ if (wantsDevtools && headless) {
620
+ throw new Error('--devtools requires --no-headless or --visible mode');
621
+ }
622
+ const idleTimeoutMs = headless ? parsedIdleTimeoutMs : 0;
623
+ const targetUrl = explicitUrl || implicitUrl;
624
+ const result = await callAPI('start', {
625
+ profileId,
626
+ url: targetUrl ? ensureUrlScheme(targetUrl) : undefined,
627
+ headless,
628
+ devtools: wantsDevtools,
629
+ ...(wantsRecord ? { record: true } : {}),
630
+ ...(recordName ? { recordName } : {}),
631
+ ...(recordOutput ? { recordOutput } : {}),
632
+ ...(recordOverlay !== null ? { recordOverlay } : {}),
633
+ });
634
+
635
+ if (result?.ok) {
636
+ const sessionId = result.sessionId || result.profileId || profileId;
637
+ acquireLock(profileId, { sessionId });
638
+ const record = registerSession(profileId, {
639
+ sessionId,
640
+ url: targetUrl,
641
+ headless,
642
+ alias,
643
+ idleTimeoutMs,
644
+ lastAction: 'start',
645
+ });
646
+ startSessionWatchdog(profileId);
647
+ result.instanceId = record.instanceId;
648
+ result.alias = record.alias || null;
649
+ result.idleTimeoutMs = idleTimeoutMs;
650
+ result.idleTimeout = formatDurationMs(idleTimeoutMs);
651
+ result.closeHint = {
652
+ byProfile: `camo stop ${profileId}`,
653
+ byId: `camo stop --id ${record.instanceId}`,
654
+ byAlias: record.alias ? `camo stop --alias ${record.alias}` : null,
655
+ all: 'camo close all',
656
+ };
657
+ result.message = headless
658
+ ? `Started session. Idle timeout: ${formatDurationMs(idleTimeoutMs)}`
659
+ : 'Started visible session. Remember to stop it when finished.';
660
+
661
+ if (!headless) {
662
+ let windowTarget = null;
663
+ if (hasExplicitWindowSize) {
664
+ windowTarget = {
665
+ width: Math.floor(explicitWidth),
666
+ height: Math.floor(explicitHeight),
667
+ source: 'explicit',
668
+ };
669
+ } else {
670
+ const display = await callAPI('system:display', {}).catch(() => null);
671
+ const displayTarget = computeStartWindowSize(display);
672
+ const rememberedWindow = getProfileWindowSize(profileId);
673
+ if (rememberedWindow) {
674
+ const rememberedTarget = {
675
+ width: rememberedWindow.width,
676
+ height: rememberedWindow.height,
677
+ source: 'profile',
678
+ updatedAt: rememberedWindow.updatedAt,
679
+ };
680
+ const canTrustDisplayTarget = displayTarget?.source && displayTarget.source !== 'fallback';
681
+ const refreshFromDisplay = canTrustDisplayTarget
682
+ && (
683
+ rememberedTarget.height < Math.floor(displayTarget.height * 0.92)
684
+ || rememberedTarget.width < Math.floor(displayTarget.width * 0.92)
685
+ );
686
+ windowTarget = refreshFromDisplay ? {
687
+ ...displayTarget,
688
+ source: 'display',
689
+ } : rememberedTarget;
690
+ } else {
691
+ windowTarget = displayTarget;
692
+ }
693
+ }
694
+
695
+ result.startWindow = {
696
+ width: windowTarget.width,
697
+ height: windowTarget.height,
698
+ source: windowTarget.source,
699
+ };
700
+
701
+ const syncResult = await syncWindowViewportAfterResize(
702
+ profileId,
703
+ windowTarget.width,
704
+ windowTarget.height,
705
+ ).catch((err) => ({ error: err?.message || String(err) }));
706
+ result.windowSync = syncResult;
707
+
708
+ const measuredOuterWidth = Number(syncResult?.verified?.outerWidth);
709
+ const measuredOuterHeight = Number(syncResult?.verified?.outerHeight);
710
+ const savedWindow = setProfileWindowSize(
711
+ profileId,
712
+ Number.isFinite(measuredOuterWidth) ? measuredOuterWidth : windowTarget.width,
713
+ Number.isFinite(measuredOuterHeight) ? measuredOuterHeight : windowTarget.height,
714
+ );
715
+ result.profileWindow = savedWindow?.window || null;
716
+ if (wantsDevtools) {
717
+ result.devtools = await requestDevtoolsOpen(profileId);
718
+ }
719
+ }
720
+ }
721
+ console.log(JSON.stringify(result, null, 2));
722
+ }
723
+
724
+ export async function handleStopCommand(args) {
725
+ const rawTarget = String(args[1] || '').trim();
726
+ const target = rawTarget.toLowerCase();
727
+ const idTarget = readFlagValue(args, ['--id']);
728
+ const aliasTarget = readFlagValue(args, ['--alias']);
729
+ if (args.includes('--id') && !idTarget) {
730
+ throw new Error('Usage: camo stop --id <instanceId>');
731
+ }
732
+ if (args.includes('--alias') && !aliasTarget) {
733
+ throw new Error('Usage: camo stop --alias <alias>');
734
+ }
735
+ const stopIdle = target === 'idle' || args.includes('--idle');
736
+ const stopAll = target === 'all';
737
+ const serviceUp = await checkBrowserService();
738
+ const resolvedSessions = await loadResolvedSessions(serviceUp);
739
+
740
+ if (stopAll) {
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);
747
+ }
748
+ }
749
+
750
+ const results = [];
751
+ for (const profileId of profileSet) {
752
+ // eslint-disable-next-line no-await-in-loop
753
+ results.push(await stopAndCleanupProfile(profileId, { serviceUp }));
754
+ }
755
+ console.log(JSON.stringify({
756
+ ok: true,
757
+ mode: 'all',
758
+ serviceUp,
759
+ closed: results.filter((item) => item.ok).length,
760
+ failed: results.filter((item) => !item.ok).length,
761
+ results,
762
+ }, null, 2));
763
+ return;
764
+ }
765
+
766
+ if (stopIdle) {
767
+ const now = Date.now();
768
+ const registeredSessions = listRegisteredSessions();
769
+ const regMap = new Map(
770
+ registeredSessions
771
+ .filter((item) => item && String(item?.status || '').trim() === 'active')
772
+ .map((item) => [String(item.profileId || '').trim(), item]),
773
+ );
774
+ const idleTargets = new Set(
775
+ registeredSessions
776
+ .filter((item) => String(item?.status || '').trim() === 'active')
777
+ .map((item) => ({ session: item, idle: computeIdleState(item, now) }))
778
+ .filter((item) => item.idle.idle)
779
+ .map((item) => item.session.profileId),
780
+ );
781
+ let orphanLiveHeadlessCount = 0;
782
+ for (const live of resolvedSessions.filter((item) => item.live)) {
783
+ const liveProfileId = String(live?.profileId || '').trim();
784
+ if (!liveProfileId) continue;
785
+ if (regMap.has(liveProfileId) || idleTargets.has(liveProfileId)) continue;
786
+ const mode = String(live?.mode || '').toLowerCase();
787
+ const liveHeadless = live?.headless === true || mode.includes('headless');
788
+ // Live but unregistered headless sessions are treated as idle-orphan targets.
789
+ if (liveHeadless) {
790
+ idleTargets.add(liveProfileId);
791
+ orphanLiveHeadlessCount += 1;
792
+ }
793
+ }
794
+ const results = [];
795
+ for (const profileId of idleTargets) {
796
+ // eslint-disable-next-line no-await-in-loop
797
+ results.push(await stopAndCleanupProfile(profileId, { serviceUp }));
798
+ }
799
+ console.log(JSON.stringify({
800
+ ok: true,
801
+ mode: 'idle',
802
+ serviceUp,
803
+ targetCount: idleTargets.size,
804
+ orphanLiveHeadlessCount,
805
+ closed: results.filter((item) => item.ok).length,
806
+ failed: results.filter((item) => !item.ok).length,
807
+ results,
808
+ }, null, 2));
809
+ return;
810
+ }
811
+
812
+ let profileId = null;
813
+ let resolvedBy = 'profile';
814
+ if (idTarget) {
815
+ const resolved = resolveSessionTarget(idTarget);
816
+ if (!resolved) throw new Error(`No session found for instance id: ${idTarget}`);
817
+ profileId = resolved.profileId;
818
+ resolvedBy = resolved.reason;
819
+ } else if (aliasTarget) {
820
+ const resolved = resolveSessionTarget(aliasTarget);
821
+ if (!resolved) throw new Error(`No session found for alias: ${aliasTarget}`);
822
+ profileId = resolved.profileId;
823
+ resolvedBy = resolved.reason;
824
+ } else {
825
+ const positional = args.slice(1).find((arg) => arg && !String(arg).startsWith('--')) || null;
826
+ if (positional) {
827
+ const resolved = resolveSessionTarget(positional);
828
+ if (resolved) {
829
+ profileId = resolved.profileId;
830
+ resolvedBy = resolved.reason;
831
+ } else {
832
+ profileId = positional;
833
+ }
834
+ }
835
+ }
836
+
837
+ if (!profileId) {
838
+ profileId = getDefaultProfile();
839
+ }
840
+ if (!profileId) {
841
+ throw new Error('Usage: camo stop [profileId] | camo stop --id <instanceId> | camo stop --alias <alias> | camo stop all | camo stop idle');
842
+ }
843
+
844
+ const result = await stopAndCleanupProfile(profileId, { serviceUp });
845
+ if (!result.ok && serviceUp) {
846
+ throw new Error(result.error || `stop failed for profile: ${profileId}`);
847
+ }
848
+ console.log(JSON.stringify({
849
+ ok: true,
850
+ profileId,
851
+ resolvedBy,
852
+ serviceUp,
853
+ warning: (!serviceUp && !result.ok) ? result.error : null,
854
+ result: result.result || null,
855
+ }, null, 2));
856
+ }
857
+
858
+ export async function handleStatusCommand(args) {
859
+ await ensureBrowserService();
860
+ const profileId = args[1];
861
+ if (profileId && args[0] === 'status') {
862
+ const sessions = await getResolvedSessions();
863
+ const session = sessions.find((item) => item.profileId === profileId) || null;
864
+ console.log(JSON.stringify({ ok: true, session }, null, 2));
865
+ return;
866
+ }
867
+ const sessions = await getResolvedSessions();
868
+ console.log(JSON.stringify({ ok: true, sessions, count: sessions.length }, null, 2));
869
+ }
870
+
871
+ export async function handleGotoCommand(args) {
872
+ await ensureBrowserService();
873
+ const positionals = getPositionals(args);
874
+ const profileSet = new Set(listProfiles());
875
+
876
+ let profileId;
877
+ let url;
878
+
879
+ if (positionals.length === 1) {
880
+ profileId = getDefaultProfile();
881
+ url = positionals[0];
882
+ } else {
883
+ profileId = resolveProfileId(positionals, 0, getDefaultProfile);
884
+ url = positionals[1];
885
+ }
886
+
887
+ if (!profileId) throw new Error('Usage: camo goto [profileId] <url> (or set default profile first)');
888
+ if (!url) throw new Error('Usage: camo goto [profileId] <url>');
889
+ assertExistingProfile(profileId, profileSet);
890
+ const active = await getSessionByProfile(profileId);
891
+ if (!active) {
892
+ throw new Error(
893
+ `No active session for profile: ${profileId}. Start via "camo start ${profileId}" (or UI CLI startup) before goto.`,
894
+ );
895
+ }
896
+
897
+ const result = await callAPI('goto', { profileId, url: ensureUrlScheme(url) });
898
+ updateSession(profileId, { url: ensureUrlScheme(url), lastSeen: Date.now() });
899
+ console.log(JSON.stringify(result, null, 2));
900
+ }
901
+
902
+ export async function handleBackCommand(args) {
903
+ await ensureBrowserService();
904
+ const profileId = resolveProfileId(args, 1, getDefaultProfile);
905
+ if (!profileId) throw new Error('Usage: camo back [profileId] (or set default profile first)');
906
+ const result = await callAPI('page:back', { profileId });
907
+ console.log(JSON.stringify(result, null, 2));
908
+ }
909
+
910
+ export async function handleScreenshotCommand(args) {
911
+ await ensureBrowserService();
912
+ const fullPage = args.includes('--full');
913
+ const outputIdx = args.indexOf('--output');
914
+ const output = outputIdx >= 0 ? args[outputIdx + 1] : null;
915
+
916
+ let profileId = null;
917
+ for (let i = 1; i < args.length; i++) {
918
+ const arg = args[i];
919
+ if (arg === '--full') continue;
920
+ if (arg === '--output') { i++; continue; }
921
+ if (arg.startsWith('--')) continue;
922
+ profileId = arg;
923
+ break;
924
+ }
925
+
926
+ if (!profileId) profileId = getDefaultProfile();
927
+ if (!profileId) throw new Error('Usage: camo screenshot [profileId] [--output <file>] [--full]');
928
+
929
+ const result = await callAPI('screenshot', { profileId, fullPage });
930
+
931
+ if (output && result?.data) {
932
+ fs.writeFileSync(output, Buffer.from(result.data, 'base64'));
933
+ console.log(`Screenshot saved to ${output}`);
934
+ return;
935
+ }
936
+
937
+ console.log(JSON.stringify(result, null, 2));
938
+ }
939
+
940
+ export async function handleScrollCommand(args) {
941
+ await ensureBrowserService();
942
+ const directionFlags = new Set(['--up', '--down', '--left', '--right']);
943
+ const isFlag = (arg) => arg?.startsWith('--');
944
+ const selectorIdx = args.indexOf('--selector');
945
+ const selector = selectorIdx >= 0 ? String(args[selectorIdx + 1] || '').trim() : null;
946
+ const highlightRequested = resolveHighlightEnabled(args);
947
+ const highlight = highlightRequested;
948
+
949
+ let profileId = null;
950
+ for (let i = 1; i < args.length; i++) {
951
+ const arg = args[i];
952
+ if (directionFlags.has(arg)) continue;
953
+ if (arg === '--amount') { i++; continue; }
954
+ if (arg === '--selector') { i++; continue; }
955
+ if (arg === '--highlight' || arg === '--no-highlight') continue;
956
+ if (isFlag(arg)) continue;
957
+ profileId = arg;
958
+ break;
959
+ }
960
+ if (!profileId) profileId = getDefaultProfile();
961
+ if (!profileId) throw new Error('Usage: camo scroll [profileId] [--down|--up|--left|--right] [--amount <px>] [--selector <css>] [--highlight|--no-highlight]');
962
+ if (selectorIdx >= 0 && !selector) {
963
+ throw new Error('Usage: camo scroll [profileId] --selector <css>');
964
+ }
965
+
966
+ const direction = args.includes('--up') ? 'up' : args.includes('--left') ? 'left' : args.includes('--right') ? 'right' : 'down';
967
+ const amountIdx = args.indexOf('--amount');
968
+ const amount = amountIdx >= 0 ? Number(args[amountIdx + 1]) || 300 : 300;
969
+
970
+ const target = await callAPI('evaluate', {
971
+ profileId,
972
+ script: buildScrollTargetScript({ selector, highlight, requireVisibleContainer: Boolean(selector) }),
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
+ }
978
+ const deltaX = direction === 'left' ? -amount : direction === 'right' ? amount : 0;
979
+ const deltaY = direction === 'up' ? -amount : direction === 'down' ? amount : 0;
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 });
995
+ console.log(JSON.stringify({
996
+ ...result,
997
+ scrollTarget,
998
+ highlight,
999
+ }, null, 2));
1000
+ }
1001
+
1002
+ export async function handleClickCommand(args) {
1003
+ await ensureBrowserService();
1004
+ const positionals = getPositionals(args);
1005
+ const highlight = resolveHighlightEnabled(args);
1006
+ let profileId;
1007
+ let selector;
1008
+
1009
+ if (positionals.length === 1) {
1010
+ profileId = getDefaultProfile();
1011
+ selector = positionals[0];
1012
+ } else {
1013
+ profileId = positionals[0];
1014
+ selector = positionals[1];
1015
+ }
1016
+
1017
+ if (!profileId) throw new Error('Usage: camo click [profileId] <selector> [--highlight|--no-highlight]');
1018
+ if (!selector) throw new Error('Usage: camo click [profileId] <selector> [--highlight|--no-highlight]');
1019
+
1020
+ let target = await resolveVisibleTargetPoint(profileId, selector, { highlight });
1021
+ const ensured = await ensureClickTargetInViewport(profileId, selector, target, {
1022
+ maxAutoScrollSteps: 3,
1023
+ });
1024
+ if (!ensured.targetFullyVisible) {
1025
+ throw new Error(`Click target not fully visible after ${ensured.autoScrolled} auto-scroll attempts: ${selector}`);
1026
+ }
1027
+ target = ensured.target;
1028
+ if (highlight) {
1029
+ target = await resolveVisibleTargetPoint(profileId, selector, { highlight: true });
1030
+ }
1031
+ const result = await callAPI('mouse:click', {
1032
+ profileId,
1033
+ x: target.center.x,
1034
+ y: target.center.y,
1035
+ button: 'left',
1036
+ clicks: 1,
1037
+ }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
1038
+ console.log(JSON.stringify({
1039
+ ...result,
1040
+ selector,
1041
+ highlight,
1042
+ autoScrolled: ensured.autoScrolled,
1043
+ targetFullyVisible: ensured.targetFullyVisible,
1044
+ target,
1045
+ }, null, 2));
1046
+ }
1047
+
1048
+ export async function handleTypeCommand(args) {
1049
+ await ensureBrowserService();
1050
+ const positionals = getPositionals(args);
1051
+ const highlight = resolveHighlightEnabled(args);
1052
+ let profileId;
1053
+ let selector;
1054
+ let text;
1055
+
1056
+ if (positionals.length === 2) {
1057
+ profileId = getDefaultProfile();
1058
+ selector = positionals[0];
1059
+ text = positionals[1];
1060
+ } else {
1061
+ profileId = positionals[0];
1062
+ selector = positionals[1];
1063
+ text = positionals[2];
1064
+ }
1065
+
1066
+ if (!profileId) throw new Error('Usage: camo type [profileId] <selector> <text> [--highlight|--no-highlight]');
1067
+ if (!selector || text === undefined) throw new Error('Usage: camo type [profileId] <selector> <text> [--highlight|--no-highlight]');
1068
+
1069
+ const target = await resolveVisibleTargetPoint(profileId, selector, { highlight });
1070
+ await callAPI('mouse:click', {
1071
+ profileId,
1072
+ x: target.center.x,
1073
+ y: target.center.y,
1074
+ button: 'left',
1075
+ clicks: 1,
1076
+ }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
1077
+ await callAPI('keyboard:press', {
1078
+ profileId,
1079
+ key: process.platform === 'darwin' ? 'Meta+A' : 'Control+A',
1080
+ }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
1081
+ await callAPI('keyboard:press', { profileId, key: 'Backspace' }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
1082
+ const result = await callAPI('keyboard:type', {
1083
+ profileId,
1084
+ text: String(text),
1085
+ }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
1086
+ console.log(JSON.stringify({
1087
+ ...result,
1088
+ selector,
1089
+ typed: String(text).length,
1090
+ highlight,
1091
+ target,
1092
+ }, null, 2));
1093
+ }
1094
+
1095
+ export async function handleHighlightCommand(args) {
1096
+ await ensureBrowserService();
1097
+ const positionals = getPositionals(args);
1098
+ let profileId;
1099
+ let selector;
1100
+
1101
+ if (positionals.length === 1) {
1102
+ profileId = getDefaultProfile();
1103
+ selector = positionals[0];
1104
+ } else {
1105
+ profileId = positionals[0];
1106
+ selector = positionals[1];
1107
+ }
1108
+
1109
+ if (!profileId) throw new Error('Usage: camo highlight [profileId] <selector>');
1110
+ if (!selector) throw new Error('Usage: camo highlight [profileId] <selector>');
1111
+
1112
+ const result = await callAPI('browser:highlight', {
1113
+ profile: profileId,
1114
+ profileId,
1115
+ selector,
1116
+ });
1117
+ console.log(JSON.stringify(result, null, 2));
1118
+ }
1119
+
1120
+ export async function handleClearHighlightCommand(args) {
1121
+ await ensureBrowserService();
1122
+ const profileId = resolveProfileId(args, 1, getDefaultProfile);
1123
+ if (!profileId) throw new Error('Usage: camo clear-highlight [profileId]');
1124
+
1125
+ const result = await callAPI('browser:clear-highlight', {
1126
+ profile: profileId,
1127
+ profileId,
1128
+ });
1129
+ console.log(JSON.stringify(result, null, 2));
1130
+ }
1131
+
1132
+ export async function handleViewportCommand(args) {
1133
+ await ensureBrowserService();
1134
+ const profileId = resolveProfileId(args, 1, getDefaultProfile);
1135
+ if (!profileId) throw new Error('Usage: camo viewport [profileId] --width <w> --height <h>');
1136
+
1137
+ const widthIdx = args.indexOf('--width');
1138
+ const heightIdx = args.indexOf('--height');
1139
+ const width = widthIdx >= 0 ? Number(args[widthIdx + 1]) : 1280;
1140
+ const height = heightIdx >= 0 ? Number(args[heightIdx + 1]) : 800;
1141
+
1142
+ if (!Number.isFinite(width) || !Number.isFinite(height)) {
1143
+ throw new Error('Usage: camo viewport [profileId] --width <w> --height <h>');
1144
+ }
1145
+
1146
+ const result = await callAPI('page:setViewport', { profileId, width, height });
1147
+ console.log(JSON.stringify(result, null, 2));
1148
+ }
1149
+
1150
+ export async function handleNewPageCommand(args) {
1151
+ await ensureBrowserService();
1152
+ const profileId = resolveProfileId(args, 1, getDefaultProfile);
1153
+ if (!profileId) throw new Error('Usage: camo new-page [profileId] [--url <url>] (or set default profile first)');
1154
+ const urlIdx = args.indexOf('--url');
1155
+ const url = urlIdx >= 0 ? args[urlIdx + 1] : undefined;
1156
+ const result = await callAPI('newPage', { profileId, ...(url ? { url: ensureUrlScheme(url) } : {}) });
1157
+ console.log(JSON.stringify(result, null, 2));
1158
+ }
1159
+
1160
+ export async function handleClosePageCommand(args) {
1161
+ await ensureBrowserService();
1162
+ const profileId = resolveProfileId(args, 1, getDefaultProfile);
1163
+ if (!profileId) throw new Error('Usage: camo close-page [profileId] [index] (or set default profile first)');
1164
+
1165
+ let index;
1166
+ for (let i = args.length - 1; i >= 1; i--) {
1167
+ const arg = args[i];
1168
+ if (arg.startsWith('--')) continue;
1169
+ const num = Number(arg);
1170
+ if (Number.isFinite(num)) { index = num; break; }
1171
+ }
1172
+
1173
+ const result = await callAPI('page:close', { profileId, ...(Number.isFinite(index) ? { index } : {}) });
1174
+ console.log(JSON.stringify(result, null, 2));
1175
+ }
1176
+
1177
+ export async function handleSwitchPageCommand(args) {
1178
+ await ensureBrowserService();
1179
+ const profileId = resolveProfileId(args, 1, getDefaultProfile);
1180
+ if (!profileId) throw new Error('Usage: camo switch-page [profileId] <index> (or set default profile first)');
1181
+
1182
+ let index;
1183
+ for (let i = args.length - 1; i >= 1; i--) {
1184
+ const arg = args[i];
1185
+ if (arg.startsWith('--')) continue;
1186
+ const num = Number(arg);
1187
+ if (Number.isFinite(num)) { index = num; break; }
1188
+ }
1189
+
1190
+ if (!Number.isFinite(index)) throw new Error('Usage: camo switch-page [profileId] <index>');
1191
+ const result = await callAPI('page:switch', { profileId, index });
1192
+ console.log(JSON.stringify(result, null, 2));
1193
+ }
1194
+
1195
+ export async function handleListPagesCommand(args) {
1196
+ await ensureBrowserService();
1197
+ const profileId = resolveProfileId(args, 1, getDefaultProfile);
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
+ const result = await callAPI('page:list', { profileId });
1205
+ console.log(JSON.stringify(result, null, 2));
1206
+ }
1207
+
1208
+ export async function handleShutdownCommand() {
1209
+ await ensureBrowserService();
1210
+
1211
+ // Get all active sessions
1212
+ const status = await callAPI('getStatus', {});
1213
+ const sessions = Array.isArray(status?.sessions) ? status.sessions : [];
1214
+
1215
+ // Stop each session and cleanup registry
1216
+ for (const session of sessions) {
1217
+ try {
1218
+ await callAPI('stop', { profileId: session.profileId });
1219
+ } catch {
1220
+ // Best effort cleanup
1221
+ }
1222
+ stopSessionWatchdog(session.profileId);
1223
+ releaseLock(session.profileId);
1224
+ markSessionClosed(session.profileId);
1225
+ }
1226
+
1227
+ // Cleanup any remaining registry entries
1228
+ const registered = listRegisteredSessions();
1229
+ for (const reg of registered) {
1230
+ if (reg.status !== 'closed') {
1231
+ stopSessionWatchdog(reg.profileId);
1232
+ markSessionClosed(reg.profileId);
1233
+ releaseLock(reg.profileId);
1234
+ }
1235
+ }
1236
+ stopAllSessionWatchdogs();
1237
+
1238
+ const result = await callAPI('service:shutdown', {});
1239
+ console.log(JSON.stringify(result, null, 2));
1240
+ }
1241
+
1242
+ export async function handleSessionsCommand(args) {
1243
+ const serviceUp = await checkBrowserService();
1244
+ const merged = await loadResolvedSessions(serviceUp);
1245
+ const registeredSessions = listRegisteredSessions();
1246
+
1247
+ console.log(JSON.stringify({
1248
+ ok: true,
1249
+ serviceUp,
1250
+ sessions: merged,
1251
+ count: merged.length,
1252
+ registered: registeredSessions.length,
1253
+ live: liveSessions.length,
1254
+ }, null, 2));
1255
+ }