@web-auto/camo 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,8 +33,14 @@ camo profile create myprofile
33
33
  # Set as default
34
34
  camo profile default myprofile
35
35
 
36
- # Start browser
37
- camo start --url https://example.com
36
+ # Start browser (with alias)
37
+ camo start --url https://example.com --alias main
38
+
39
+ # Start headless worker (auto-kill after idle timeout)
40
+ camo start worker-1 --headless --alias shard1 --idle-timeout 30m
41
+
42
+ # Start with devtools (headful only)
43
+ camo start worker-1 --devtools
38
44
 
39
45
  # Navigate
40
46
  camo goto https://www.xiaohongshu.com
@@ -68,8 +74,12 @@ camo create fingerprint --os <os> --region <region>
68
74
  ### Browser Control
69
75
 
70
76
  ```bash
71
- camo start [profileId] [--url <url>] [--headless] [--width <w> --height <h>]
77
+ camo start [profileId] [--url <url>] [--headless] [--devtools] [--alias <name>] [--idle-timeout <duration>] [--width <w> --height <h>]
72
78
  camo stop [profileId]
79
+ camo stop --id <instanceId>
80
+ camo stop --alias <alias>
81
+ camo stop idle
82
+ camo stop all
73
83
  camo status [profileId]
74
84
  camo shutdown # Shutdown browser-service (all sessions)
75
85
  ```
@@ -77,10 +87,13 @@ camo shutdown # Shutdown browser-service (all sessions)
77
87
  `camo start` in headful mode now persists window size per profile and reuses that size on next start.
78
88
  If no saved size exists, it defaults to near-fullscreen (full width, slight vertical reserve).
79
89
  Use `--width/--height` to override and update the saved profile size.
90
+ For headless sessions, default idle timeout is `30m` (auto-stop on inactivity). Use `--idle-timeout` (e.g. `45m`, `1800s`, `0`) to customize.
91
+ Use `--devtools` to open browser developer tools in headed mode (cannot be combined with `--headless`).
80
92
 
81
93
  ### Lifecycle & Cleanup
82
94
 
83
95
  ```bash
96
+ camo instances # List global camoufox instances (live + orphaned + idle state)
84
97
  camo sessions # List active browser sessions
85
98
  camo cleanup [profileId] # Cleanup session (release lock + stop)
86
99
  camo cleanup all # Cleanup all active sessions
@@ -332,7 +345,7 @@ Condition types:
332
345
  Camo CLI persists session information locally:
333
346
 
334
347
  - Sessions are registered in `~/.webauto/sessions/`
335
- - On restart, `camo sessions` shows both live and orphaned sessions
348
+ - On restart, `camo sessions` / `camo instances` shows live + orphaned sessions
336
349
  - Use `camo recover <profileId>` to reconnect or cleanup orphaned sessions
337
350
  - Stale sessions (>7 days) are automatically cleaned up
338
351
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@web-auto/camo",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Camoufox Browser CLI - Cross-platform browser automation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -86,6 +86,43 @@ export function buildCommentsHarvestScript(params = {}) {
86
86
  }
87
87
  return null;
88
88
  };
89
+ const normalizeInlineText = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
90
+ const sanitizeAuthorText = (raw, commentText = '') => {
91
+ const text = normalizeInlineText(raw);
92
+ if (!text) return '';
93
+ if (commentText && text === commentText) return '';
94
+ if (text.length > 40) return '';
95
+ if (/^(回复|展开|收起|查看更多|评论|赞|分享|发送)$/.test(text)) return '';
96
+ return text;
97
+ };
98
+ const readAuthor = (item, commentText = '') => {
99
+ const attrNames = ['data-user-name', 'data-username', 'data-user_nickname', 'data-nickname'];
100
+ for (const attr of attrNames) {
101
+ const value = sanitizeAuthorText(item.getAttribute?.(attr), commentText);
102
+ if (value) return value;
103
+ }
104
+ const selectors = [
105
+ '.comment-user .name',
106
+ '.comment-user .username',
107
+ '.comment-user .user-name',
108
+ '.author .name',
109
+ '.author',
110
+ '.user-name',
111
+ '.username',
112
+ '.name',
113
+ 'a[href*="/user/profile/"]',
114
+ 'a[href*="/user/"]',
115
+ ];
116
+ for (const selector of selectors) {
117
+ const node = item.querySelector(selector);
118
+ if (!node) continue;
119
+ const title = sanitizeAuthorText(node.getAttribute?.('title'), commentText);
120
+ if (title) return title;
121
+ const text = sanitizeAuthorText(node.textContent, commentText);
122
+ if (text) return text;
123
+ }
124
+ return '';
125
+ };
89
126
 
90
127
  const scroller = document.querySelector('.note-scroller')
91
128
  || document.querySelector('.comments-el')
@@ -105,9 +142,8 @@ export function buildCommentsHarvestScript(params = {}) {
105
142
  const nodes = Array.from(document.querySelectorAll('.comment-item, [class*="comment-item"]'));
106
143
  for (const item of nodes) {
107
144
  const textNode = item.querySelector('.content, .comment-content, p');
108
- const authorNode = item.querySelector('.name, .author, .user-name, [class*="author"], [class*="name"]');
109
145
  const text = String((textNode && textNode.textContent) || '').trim();
110
- const author = String((authorNode && authorNode.textContent) || '').trim();
146
+ const author = readAuthor(item, text);
111
147
  if (!text) continue;
112
148
  const key = author + '::' + text;
113
149
  if (commentMap.has(key)) continue;
@@ -74,6 +74,42 @@ function buildCollectLikeTargetsScript() {
74
74
  }
75
75
  return '';
76
76
  };
77
+ const sanitizeUserName = (raw, commentText = '') => {
78
+ const text = String(raw || '').replace(/\\s+/g, ' ').trim();
79
+ if (!text) return '';
80
+ if (commentText && text === commentText) return '';
81
+ if (text.length > 40) return '';
82
+ if (/^(回复|展开|收起|查看更多|评论|赞|分享|发送)$/.test(text)) return '';
83
+ return text;
84
+ };
85
+ const readUserName = (item, commentText = '') => {
86
+ const attrNames = ['data-user-name', 'data-username', 'data-user_nickname', 'data-nickname'];
87
+ for (const attr of attrNames) {
88
+ const value = sanitizeUserName(item.getAttribute?.(attr), commentText);
89
+ if (value) return value;
90
+ }
91
+ const selectors = [
92
+ '.comment-user .name',
93
+ '.comment-user .username',
94
+ '.comment-user .user-name',
95
+ '.author .name',
96
+ '.author',
97
+ '.user-name',
98
+ '.username',
99
+ '.name',
100
+ 'a[href*="/user/profile/"]',
101
+ 'a[href*="/user/"]',
102
+ ];
103
+ for (const selector of selectors) {
104
+ const node = item.querySelector(selector);
105
+ if (!node) continue;
106
+ const title = sanitizeUserName(node.getAttribute?.('title'), commentText);
107
+ if (title) return title;
108
+ const text = sanitizeUserName(node.textContent, commentText);
109
+ if (text) return text;
110
+ }
111
+ return '';
112
+ };
77
113
  const readAttr = (item, attrNames) => {
78
114
  for (const attr of attrNames) {
79
115
  const value = String(item.getAttribute?.(attr) || '').trim();
@@ -81,6 +117,15 @@ function buildCollectLikeTargetsScript() {
81
117
  }
82
118
  return '';
83
119
  };
120
+ const readUserId = (item) => {
121
+ const value = readAttr(item, ['data-user-id', 'data-userid', 'data-user_id']);
122
+ if (value) return value;
123
+ const anchor = item.querySelector('a[href*="/user/profile/"], a[href*="/user/"]');
124
+ const href = String(anchor?.getAttribute?.('href') || '').trim();
125
+ if (!href) return '';
126
+ const matched = href.match(/\\/user\\/(?:profile\\/)?([a-zA-Z0-9_-]+)/);
127
+ return matched && matched[1] ? matched[1] : '';
128
+ };
84
129
 
85
130
  const matchedSet = new Set(
86
131
  Array.isArray(state.matchedComments)
@@ -92,8 +137,8 @@ function buildCollectLikeTargetsScript() {
92
137
  const item = items[index];
93
138
  const text = readText(item, ['.content', '.comment-content', 'p']);
94
139
  if (!text) continue;
95
- const userName = readText(item, ['.name', '.author', '.user-name', '.username', '[class*="author"]', '[class*="name"]']);
96
- const userId = readAttr(item, ['data-user-id', 'data-userid', 'data-user_id']);
140
+ const userName = readUserName(item, text);
141
+ const userId = readUserId(item);
97
142
  const timestamp = readText(item, ['.date', '.time', '.timestamp', '[class*="time"]']);
98
143
  const likeControl = findLikeControl(item);
99
144
  rows.push({
package/src/cli.mjs CHANGED
@@ -2,7 +2,6 @@
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import path from 'node:path';
4
4
  import { listProfiles, getDefaultProfile, loadConfig, hasStartScript, setRepoRoot } from './utils/config.mjs';
5
- import { ensureCamoufox, ensureBrowserService } from './utils/browser-service.mjs';
6
5
  import { printHelp, printProfilesAndHint } from './utils/help.mjs';
7
6
  import { handleProfileCommand } from './commands/profile.mjs';
8
7
  import { handleInitCommand } from './commands/init.mjs';
@@ -24,7 +23,7 @@ import {
24
23
  } from './commands/browser.mjs';
25
24
  import {
26
25
  handleCleanupCommand, handleForceStopCommand, handleLockCommand,
27
- handleUnlockCommand, handleSessionsCommand
26
+ handleUnlockCommand, handleSessionsCommand, handleInstancesCommand
28
27
  } from './commands/lifecycle.mjs';
29
28
  import { handleSessionWatchdogCommand } from './lifecycle/session-watchdog.mjs';
30
29
  import { safeAppendProgressEvent } from './events/progress-log.mjs';
@@ -55,6 +54,13 @@ function inferProfileId(cmd, args) {
55
54
  'new-page', 'close-page', 'switch-page', 'list-pages',
56
55
  'cleanup', 'force-stop', 'lock', 'unlock', 'sessions',
57
56
  ].includes(cmd)) {
57
+ if ((cmd === 'stop' || cmd === 'close') && (args.includes('--id') || args.includes('--alias'))) {
58
+ return null;
59
+ }
60
+ const first = positionals[0] || null;
61
+ if ((cmd === 'stop' || cmd === 'close') && (first === 'all' || first === 'idle')) {
62
+ return null;
63
+ }
58
64
  return positionals[0] || null;
59
65
  }
60
66
 
@@ -212,6 +218,11 @@ async function main() {
212
218
  return;
213
219
  }
214
220
 
221
+ if (cmd === 'instances') {
222
+ await runTrackedCommand(cmd, args, () => handleInstancesCommand(args));
223
+ return;
224
+ }
225
+
215
226
  if (cmd === 'container') {
216
227
  await runTrackedCommand(cmd, args, () => handleContainerCommand(args));
217
228
  return;
@@ -7,14 +7,32 @@ import {
7
7
  } from '../utils/config.mjs';
8
8
  import { callAPI, ensureCamoufox, ensureBrowserService, getSessionByProfile, checkBrowserService } from '../utils/browser-service.mjs';
9
9
  import { resolveProfileId, ensureUrlScheme, looksLikeUrlToken, getPositionals } from '../utils/args.mjs';
10
- import { acquireLock, releaseLock, isLocked, getLockInfo, cleanupStaleLocks } from '../lifecycle/lock.mjs';
11
- import { registerSession, updateSession, getSessionInfo, unregisterSession, listRegisteredSessions, markSessionClosed, cleanupStaleSessions, recoverSession } from '../lifecycle/session-registry.mjs';
10
+ import { acquireLock, releaseLock, cleanupStaleLocks } from '../lifecycle/lock.mjs';
11
+ import {
12
+ buildSelectorClickScript,
13
+ buildSelectorTypeScript,
14
+ } from '../container/runtime-core/operations/selector-scripts.mjs';
15
+ import {
16
+ registerSession,
17
+ updateSession,
18
+ getSessionInfo,
19
+ unregisterSession,
20
+ listRegisteredSessions,
21
+ markSessionClosed,
22
+ cleanupStaleSessions,
23
+ resolveSessionTarget,
24
+ isSessionAliasTaken,
25
+ } from '../lifecycle/session-registry.mjs';
12
26
  import { startSessionWatchdog, stopAllSessionWatchdogs, stopSessionWatchdog } from '../lifecycle/session-watchdog.mjs';
13
27
 
14
28
  const START_WINDOW_MIN_WIDTH = 960;
15
29
  const START_WINDOW_MIN_HEIGHT = 700;
16
30
  const START_WINDOW_MAX_RESERVE = 240;
17
31
  const START_WINDOW_DEFAULT_RESERVE = 72;
32
+ const DEFAULT_HEADLESS_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
33
+ const DEVTOOLS_SHORTCUTS = process.platform === 'darwin'
34
+ ? ['Meta+Alt+I', 'F12']
35
+ : ['F12', 'Control+Shift+I'];
18
36
 
19
37
  function sleep(ms) {
20
38
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -29,6 +47,149 @@ function clamp(value, min, max) {
29
47
  return Math.min(Math.max(value, min), max);
30
48
  }
31
49
 
50
+ function readFlagValue(args, names) {
51
+ for (let i = 0; i < args.length; i += 1) {
52
+ if (!names.includes(args[i])) continue;
53
+ const value = args[i + 1];
54
+ if (!value || String(value).startsWith('-')) return null;
55
+ return value;
56
+ }
57
+ return null;
58
+ }
59
+
60
+ function parseDurationMs(raw, fallbackMs) {
61
+ if (raw === undefined || raw === null || String(raw).trim() === '') return fallbackMs;
62
+ const text = String(raw).trim().toLowerCase();
63
+ if (text === '0' || text === 'off' || text === 'none' || text === 'disable' || text === 'disabled') return 0;
64
+ const matched = text.match(/^(\d+(?:\.\d+)?)(ms|s|m|h)?$/);
65
+ if (!matched) {
66
+ throw new Error('Invalid --idle-timeout. Use forms like 30m, 1800s, 5000ms, 1h, 0');
67
+ }
68
+ const value = Number(matched[1]);
69
+ if (!Number.isFinite(value) || value < 0) {
70
+ throw new Error('Invalid --idle-timeout value');
71
+ }
72
+ const unit = matched[2] || 'm';
73
+ const factor = unit === 'h' ? 3600000 : unit === 'm' ? 60000 : unit === 's' ? 1000 : 1;
74
+ return Math.floor(value * factor);
75
+ }
76
+
77
+ function validateAlias(alias) {
78
+ const text = String(alias || '').trim();
79
+ if (!text) return null;
80
+ if (!/^[a-zA-Z0-9._-]+$/.test(text)) {
81
+ throw new Error('Invalid --alias. Use only letters, numbers, dot, underscore, dash.');
82
+ }
83
+ return text.slice(0, 64);
84
+ }
85
+
86
+ function formatDurationMs(ms) {
87
+ const value = Number(ms);
88
+ if (!Number.isFinite(value) || value <= 0) return 'disabled';
89
+ if (value % 3600000 === 0) return `${value / 3600000}h`;
90
+ if (value % 60000 === 0) return `${value / 60000}m`;
91
+ if (value % 1000 === 0) return `${value / 1000}s`;
92
+ return `${value}ms`;
93
+ }
94
+
95
+ function computeIdleState(session, now = Date.now()) {
96
+ const headless = session?.headless === true;
97
+ const timeoutMs = headless
98
+ ? (Number.isFinite(Number(session?.idleTimeoutMs)) ? Math.max(0, Number(session.idleTimeoutMs)) : DEFAULT_HEADLESS_IDLE_TIMEOUT_MS)
99
+ : 0;
100
+ const lastAt = Number(session?.lastActivityAt || session?.lastSeen || session?.startTime || now);
101
+ const idleMs = Math.max(0, now - (Number.isFinite(lastAt) ? lastAt : now));
102
+ const idle = headless && timeoutMs > 0 && idleMs >= timeoutMs;
103
+ return { headless, timeoutMs, idleMs, idle };
104
+ }
105
+
106
+ async function stopAndCleanupProfile(profileId, options = {}) {
107
+ const id = String(profileId || '').trim();
108
+ if (!id) return { profileId: id, ok: false, error: 'profile_required' };
109
+ const force = options.force === true;
110
+ const serviceUp = options.serviceUp === true;
111
+ let result = null;
112
+ let error = null;
113
+ if (serviceUp) {
114
+ try {
115
+ result = await callAPI('stop', force ? { profileId: id, force: true } : { profileId: id });
116
+ } catch (err) {
117
+ error = err;
118
+ }
119
+ }
120
+ stopSessionWatchdog(id);
121
+ releaseLock(id);
122
+ markSessionClosed(id);
123
+ return {
124
+ profileId: id,
125
+ ok: !error,
126
+ serviceUp,
127
+ result,
128
+ error: error ? (error.message || String(error)) : null,
129
+ };
130
+ }
131
+
132
+ async function probeViewportSize(profileId) {
133
+ try {
134
+ const payload = await callAPI('evaluate', {
135
+ profileId,
136
+ script: '(() => ({ width: Number(window.innerWidth || 0), height: Number(window.innerHeight || 0) }))()',
137
+ });
138
+ const size = payload?.result || payload?.data || payload || {};
139
+ const width = Number(size?.width || 0);
140
+ const height = Number(size?.height || 0);
141
+ if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
142
+ return { width, height };
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ export async function requestDevtoolsOpen(profileId, options = {}) {
149
+ const id = String(profileId || '').trim();
150
+ if (!id) {
151
+ return { ok: false, requested: false, reason: 'profile_required', shortcuts: [] };
152
+ }
153
+
154
+ const shortcuts = Array.isArray(options.shortcuts) && options.shortcuts.length
155
+ ? options.shortcuts.map((item) => String(item || '').trim()).filter(Boolean)
156
+ : DEVTOOLS_SHORTCUTS;
157
+ const settleMs = Math.max(0, parseNumber(options.settleMs, 180));
158
+ const before = await probeViewportSize(id);
159
+ const attempts = [];
160
+
161
+ for (const key of shortcuts) {
162
+ try {
163
+ await callAPI('keyboard:press', { profileId: id, key });
164
+ attempts.push({ key, ok: true });
165
+ if (settleMs > 0) {
166
+ // Allow browser UI animation to settle after shortcut.
167
+ // eslint-disable-next-line no-await-in-loop
168
+ await sleep(settleMs);
169
+ }
170
+ } catch (err) {
171
+ attempts.push({ key, ok: false, error: err?.message || String(err) });
172
+ }
173
+ }
174
+
175
+ const after = await probeViewportSize(id);
176
+ const beforeHeight = Number(before?.height || 0);
177
+ const afterHeight = Number(after?.height || 0);
178
+ const viewportReduced = beforeHeight > 0 && afterHeight > 0 && afterHeight < (beforeHeight - 100);
179
+ const successCount = attempts.filter((item) => item.ok).length;
180
+
181
+ return {
182
+ ok: successCount > 0,
183
+ requested: true,
184
+ shortcuts,
185
+ attempts,
186
+ before,
187
+ after,
188
+ verified: viewportReduced,
189
+ verification: viewportReduced ? 'viewport_height_reduced' : 'shortcut_dispatched',
190
+ };
191
+ }
192
+
32
193
  export function computeTargetViewportFromWindowMetrics(measured) {
33
194
  const innerWidth = Math.max(320, parseNumber(measured?.innerWidth, 0));
34
195
  const innerHeight = Math.max(240, parseNumber(measured?.innerHeight, 0));
@@ -154,8 +315,12 @@ export async function handleStartCommand(args) {
154
315
  const explicitHeight = heightIdx >= 0 ? parseNumber(args[heightIdx + 1], NaN) : NaN;
155
316
  const hasExplicitWidth = Number.isFinite(explicitWidth);
156
317
  const hasExplicitHeight = Number.isFinite(explicitHeight);
318
+ const alias = validateAlias(readFlagValue(args, ['--alias']));
319
+ const idleTimeoutRaw = readFlagValue(args, ['--idle-timeout']);
320
+ const parsedIdleTimeoutMs = parseDurationMs(idleTimeoutRaw, DEFAULT_HEADLESS_IDLE_TIMEOUT_MS);
321
+ const wantsDevtools = args.includes('--devtools');
157
322
  if (hasExplicitWidth !== hasExplicitHeight) {
158
- throw new Error('Usage: camo start [profileId] [--url <url>] [--headless] [--width <w> --height <h>]');
323
+ throw new Error('Usage: camo start [profileId] [--url <url>] [--headless] [--devtools] [--alias <name>] [--idle-timeout <duration>] [--width <w> --height <h>]');
159
324
  }
160
325
  if ((hasExplicitWidth && explicitWidth < START_WINDOW_MIN_WIDTH) || (hasExplicitHeight && explicitHeight < START_WINDOW_MIN_HEIGHT)) {
161
326
  throw new Error(`Window size too small. Minimum is ${START_WINDOW_MIN_WIDTH}x${START_WINDOW_MIN_HEIGHT}`);
@@ -169,6 +334,7 @@ export async function handleStartCommand(args) {
169
334
  const arg = args[i];
170
335
  if (arg === '--url') { i++; continue; }
171
336
  if (arg === '--width' || arg === '--height') { i++; continue; }
337
+ if (arg === '--alias' || arg === '--idle-timeout') { i++; continue; }
172
338
  if (arg === '--headless') continue;
173
339
  if (arg.startsWith('--')) continue;
174
340
 
@@ -187,24 +353,52 @@ export async function handleStartCommand(args) {
187
353
  throw new Error('No default profile set. Run: camo profile default <profileId>');
188
354
  }
189
355
  }
356
+ if (alias && isSessionAliasTaken(alias, profileId)) {
357
+ throw new Error(`Alias is already in use: ${alias}`);
358
+ }
190
359
 
191
360
  // Check for existing session in browser service
192
361
  const existing = await getSessionByProfile(profileId);
193
362
  if (existing) {
194
363
  // Session exists in browser service - update registry and lock
195
364
  acquireLock(profileId, { sessionId: existing.session_id || existing.profileId });
196
- registerSession(profileId, {
197
- sessionId: existing.session_id || existing.profileId,
198
- url: existing.current_url,
199
- mode: existing.mode,
200
- });
201
- console.log(JSON.stringify({
365
+ const saved = getSessionInfo(profileId);
366
+ const record = saved
367
+ ? updateSession(profileId, {
368
+ sessionId: existing.session_id || existing.profileId,
369
+ url: existing.current_url,
370
+ mode: existing.mode,
371
+ alias: alias || saved.alias || null,
372
+ })
373
+ : registerSession(profileId, {
374
+ sessionId: existing.session_id || existing.profileId,
375
+ url: existing.current_url,
376
+ mode: existing.mode,
377
+ alias: alias || null,
378
+ });
379
+ const idleState = computeIdleState(record);
380
+ const payload = {
202
381
  ok: true,
203
382
  sessionId: existing.session_id || existing.profileId,
383
+ instanceId: record.instanceId,
204
384
  profileId,
205
385
  message: 'Session already running',
206
386
  url: existing.current_url,
207
- }, null, 2));
387
+ alias: record.alias || null,
388
+ idleTimeoutMs: idleState.timeoutMs,
389
+ idleTimeout: formatDurationMs(idleState.timeoutMs),
390
+ closeHint: {
391
+ byProfile: `camo stop ${profileId}`,
392
+ byId: `camo stop --id ${record.instanceId}`,
393
+ byAlias: record.alias ? `camo stop --alias ${record.alias}` : null,
394
+ },
395
+ };
396
+ const existingMode = String(existing?.mode || record?.mode || '').toLowerCase();
397
+ const existingHeadless = existing?.headless === true || existingMode.includes('headless');
398
+ if (!existingHeadless && wantsDevtools) {
399
+ payload.devtools = await requestDevtoolsOpen(profileId);
400
+ }
401
+ console.log(JSON.stringify(payload, null, 2));
208
402
  startSessionWatchdog(profileId);
209
403
  return;
210
404
  }
@@ -219,22 +413,43 @@ export async function handleStartCommand(args) {
219
413
  }
220
414
 
221
415
  const headless = args.includes('--headless');
416
+ if (wantsDevtools && headless) {
417
+ throw new Error('--devtools is not supported with --headless');
418
+ }
419
+ const idleTimeoutMs = headless ? parsedIdleTimeoutMs : 0;
222
420
  const targetUrl = explicitUrl || implicitUrl;
223
421
  const result = await callAPI('start', {
224
422
  profileId,
225
423
  url: targetUrl ? ensureUrlScheme(targetUrl) : undefined,
226
424
  headless,
425
+ devtools: wantsDevtools,
227
426
  });
228
427
 
229
428
  if (result?.ok) {
230
429
  const sessionId = result.sessionId || result.profileId || profileId;
231
430
  acquireLock(profileId, { sessionId });
232
- registerSession(profileId, {
431
+ const record = registerSession(profileId, {
233
432
  sessionId,
234
433
  url: targetUrl,
235
434
  headless,
435
+ alias,
436
+ idleTimeoutMs,
437
+ lastAction: 'start',
236
438
  });
237
439
  startSessionWatchdog(profileId);
440
+ result.instanceId = record.instanceId;
441
+ result.alias = record.alias || null;
442
+ result.idleTimeoutMs = idleTimeoutMs;
443
+ result.idleTimeout = formatDurationMs(idleTimeoutMs);
444
+ result.closeHint = {
445
+ byProfile: `camo stop ${profileId}`,
446
+ byId: `camo stop --id ${record.instanceId}`,
447
+ byAlias: record.alias ? `camo stop --alias ${record.alias}` : null,
448
+ all: 'camo close all',
449
+ };
450
+ result.message = headless
451
+ ? `Started headless session. Idle timeout: ${formatDurationMs(idleTimeoutMs)}`
452
+ : 'Started session. Remember to stop it when finished.';
238
453
 
239
454
  if (!headless) {
240
455
  let windowTarget = null;
@@ -280,30 +495,161 @@ export async function handleStartCommand(args) {
280
495
  Number.isFinite(measuredOuterHeight) ? measuredOuterHeight : windowTarget.height,
281
496
  );
282
497
  result.profileWindow = savedWindow?.window || null;
498
+ if (wantsDevtools) {
499
+ result.devtools = await requestDevtoolsOpen(profileId);
500
+ }
283
501
  }
284
502
  }
285
503
  console.log(JSON.stringify(result, null, 2));
286
504
  }
287
505
 
288
506
  export async function handleStopCommand(args) {
289
- await ensureBrowserService();
290
- const profileId = resolveProfileId(args, 1, getDefaultProfile);
291
- if (!profileId) throw new Error('Usage: camo stop [profileId]');
507
+ const rawTarget = String(args[1] || '').trim();
508
+ const target = rawTarget.toLowerCase();
509
+ const idTarget = readFlagValue(args, ['--id']);
510
+ const aliasTarget = readFlagValue(args, ['--alias']);
511
+ if (args.includes('--id') && !idTarget) {
512
+ throw new Error('Usage: camo stop --id <instanceId>');
513
+ }
514
+ if (args.includes('--alias') && !aliasTarget) {
515
+ throw new Error('Usage: camo stop --alias <alias>');
516
+ }
517
+ const stopIdle = target === 'idle' || args.includes('--idle');
518
+ const stopAll = target === 'all';
519
+ const serviceUp = await checkBrowserService();
292
520
 
293
- let result = null;
294
- let stopError = null;
295
- try {
296
- result = await callAPI('stop', { profileId });
297
- } catch (err) {
298
- stopError = err;
299
- } finally {
300
- stopSessionWatchdog(profileId);
301
- releaseLock(profileId);
302
- markSessionClosed(profileId);
521
+ if (stopAll) {
522
+ let liveSessions = [];
523
+ if (serviceUp) {
524
+ try {
525
+ const status = await callAPI('getStatus', {});
526
+ liveSessions = Array.isArray(status?.sessions) ? status.sessions : [];
527
+ } catch {
528
+ // Ignore and fallback to local registry.
529
+ }
530
+ }
531
+ const profileSet = new Set(liveSessions.map((item) => String(item?.profileId || '').trim()).filter(Boolean));
532
+ for (const session of listRegisteredSessions()) {
533
+ if (String(session?.status || '').trim() === 'closed') continue;
534
+ const profileId = String(session?.profileId || '').trim();
535
+ if (profileId) profileSet.add(profileId);
536
+ }
537
+
538
+ const results = [];
539
+ for (const profileId of profileSet) {
540
+ // eslint-disable-next-line no-await-in-loop
541
+ results.push(await stopAndCleanupProfile(profileId, { serviceUp }));
542
+ }
543
+ console.log(JSON.stringify({
544
+ ok: true,
545
+ mode: 'all',
546
+ serviceUp,
547
+ closed: results.filter((item) => item.ok).length,
548
+ failed: results.filter((item) => !item.ok).length,
549
+ results,
550
+ }, null, 2));
551
+ return;
303
552
  }
304
553
 
305
- if (stopError) throw stopError;
306
- console.log(JSON.stringify(result, null, 2));
554
+ if (stopIdle) {
555
+ const now = Date.now();
556
+ const registeredSessions = listRegisteredSessions();
557
+ let liveSessions = [];
558
+ if (serviceUp) {
559
+ try {
560
+ const status = await callAPI('getStatus', {});
561
+ liveSessions = Array.isArray(status?.sessions) ? status.sessions : [];
562
+ } catch {
563
+ // Ignore and fallback to local registry.
564
+ }
565
+ }
566
+ const regMap = new Map(
567
+ registeredSessions
568
+ .filter((item) => item && String(item?.status || '').trim() === 'active')
569
+ .map((item) => [String(item.profileId || '').trim(), item]),
570
+ );
571
+ const idleTargets = new Set(
572
+ registeredSessions
573
+ .filter((item) => String(item?.status || '').trim() === 'active')
574
+ .map((item) => ({ session: item, idle: computeIdleState(item, now) }))
575
+ .filter((item) => item.idle.idle)
576
+ .map((item) => item.session.profileId),
577
+ );
578
+ let orphanLiveHeadlessCount = 0;
579
+ for (const live of liveSessions) {
580
+ const liveProfileId = String(live?.profileId || '').trim();
581
+ if (!liveProfileId) continue;
582
+ if (regMap.has(liveProfileId) || idleTargets.has(liveProfileId)) continue;
583
+ const mode = String(live?.mode || '').toLowerCase();
584
+ const liveHeadless = live?.headless === true || mode.includes('headless');
585
+ // Live but unregistered headless sessions are treated as idle-orphan targets.
586
+ if (liveHeadless) {
587
+ idleTargets.add(liveProfileId);
588
+ orphanLiveHeadlessCount += 1;
589
+ }
590
+ }
591
+ const results = [];
592
+ for (const profileId of idleTargets) {
593
+ // eslint-disable-next-line no-await-in-loop
594
+ results.push(await stopAndCleanupProfile(profileId, { serviceUp }));
595
+ }
596
+ console.log(JSON.stringify({
597
+ ok: true,
598
+ mode: 'idle',
599
+ serviceUp,
600
+ targetCount: idleTargets.size,
601
+ orphanLiveHeadlessCount,
602
+ closed: results.filter((item) => item.ok).length,
603
+ failed: results.filter((item) => !item.ok).length,
604
+ results,
605
+ }, null, 2));
606
+ return;
607
+ }
608
+
609
+ let profileId = null;
610
+ let resolvedBy = 'profile';
611
+ if (idTarget) {
612
+ const resolved = resolveSessionTarget(idTarget);
613
+ if (!resolved) throw new Error(`No session found for instance id: ${idTarget}`);
614
+ profileId = resolved.profileId;
615
+ resolvedBy = resolved.reason;
616
+ } else if (aliasTarget) {
617
+ const resolved = resolveSessionTarget(aliasTarget);
618
+ if (!resolved) throw new Error(`No session found for alias: ${aliasTarget}`);
619
+ profileId = resolved.profileId;
620
+ resolvedBy = resolved.reason;
621
+ } else {
622
+ const positional = args.slice(1).find((arg) => arg && !String(arg).startsWith('--')) || null;
623
+ if (positional) {
624
+ const resolved = resolveSessionTarget(positional);
625
+ if (resolved) {
626
+ profileId = resolved.profileId;
627
+ resolvedBy = resolved.reason;
628
+ } else {
629
+ profileId = positional;
630
+ }
631
+ }
632
+ }
633
+
634
+ if (!profileId) {
635
+ profileId = getDefaultProfile();
636
+ }
637
+ if (!profileId) {
638
+ throw new Error('Usage: camo stop [profileId] | camo stop --id <instanceId> | camo stop --alias <alias> | camo stop all | camo stop idle');
639
+ }
640
+
641
+ const result = await stopAndCleanupProfile(profileId, { serviceUp });
642
+ if (!result.ok && serviceUp) {
643
+ throw new Error(result.error || `stop failed for profile: ${profileId}`);
644
+ }
645
+ console.log(JSON.stringify({
646
+ ok: true,
647
+ profileId,
648
+ resolvedBy,
649
+ serviceUp,
650
+ warning: (!serviceUp && !result.ok) ? result.error : null,
651
+ result: result.result || null,
652
+ }, null, 2));
307
653
  }
308
654
 
309
655
  export async function handleStatusCommand(args) {
@@ -425,14 +771,7 @@ export async function handleClickCommand(args) {
425
771
 
426
772
  const result = await callAPI('evaluate', {
427
773
  profileId,
428
- script: `(async () => {
429
- const el = document.querySelector(${JSON.stringify(selector)});
430
- if (!el) throw new Error('Element not found: ' + ${JSON.stringify(selector)});
431
- el.scrollIntoView({ behavior: 'smooth', block: 'center' });
432
- await new Promise(r => setTimeout(r, 200));
433
- el.click();
434
- return { clicked: true, selector: ${JSON.stringify(selector)} };
435
- })()`
774
+ script: buildSelectorClickScript({ selector, highlight: false }),
436
775
  });
437
776
  console.log(JSON.stringify(result, null, 2));
438
777
  }
@@ -459,18 +798,7 @@ export async function handleTypeCommand(args) {
459
798
 
460
799
  const result = await callAPI('evaluate', {
461
800
  profileId,
462
- script: `(async () => {
463
- const el = document.querySelector(${JSON.stringify(selector)});
464
- if (!el) throw new Error('Element not found: ' + ${JSON.stringify(selector)});
465
- el.scrollIntoView({ behavior: 'smooth', block: 'center' });
466
- await new Promise(r => setTimeout(r, 200));
467
- el.focus();
468
- el.value = '';
469
- el.value = ${JSON.stringify(text)};
470
- el.dispatchEvent(new Event('input', { bubbles: true }));
471
- el.dispatchEvent(new Event('change', { bubbles: true }));
472
- return { typed: true, selector: ${JSON.stringify(selector)}, length: ${text.length} };
473
- })()`
801
+ script: buildSelectorTypeScript({ selector, highlight: false, text }),
474
802
  });
475
803
  console.log(JSON.stringify(result, null, 2));
476
804
  }
@@ -5,13 +5,81 @@
5
5
  import { getDefaultProfile } from '../utils/config.mjs';
6
6
  import { callAPI, ensureBrowserService, checkBrowserService } from '../utils/browser-service.mjs';
7
7
  import { resolveProfileId } from '../utils/args.mjs';
8
- import { acquireLock, getLockInfo, releaseLock, isLocked, cleanupStaleLocks, listActiveLocks } from '../lifecycle/lock.mjs';
8
+ import { acquireLock, getLockInfo, releaseLock, cleanupStaleLocks, listActiveLocks } from '../lifecycle/lock.mjs';
9
9
  import {
10
10
  getSessionInfo, unregisterSession, markSessionClosed, cleanupStaleSessions,
11
- listRegisteredSessions, registerSession, updateSession
11
+ listRegisteredSessions, updateSession
12
12
  } from '../lifecycle/session-registry.mjs';
13
13
  import { stopAllSessionWatchdogs, stopSessionWatchdog } from '../lifecycle/session-watchdog.mjs';
14
14
 
15
+ const DEFAULT_HEADLESS_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
16
+
17
+ function computeIdleState(session, now = Date.now()) {
18
+ const headless = session?.headless === true;
19
+ const timeoutMs = headless
20
+ ? (Number.isFinite(Number(session?.idleTimeoutMs)) ? Math.max(0, Number(session.idleTimeoutMs)) : DEFAULT_HEADLESS_IDLE_TIMEOUT_MS)
21
+ : 0;
22
+ const lastAt = Number(session?.lastActivityAt || session?.lastSeen || session?.startTime || now);
23
+ const idleMs = Math.max(0, now - (Number.isFinite(lastAt) ? lastAt : now));
24
+ const idle = headless && timeoutMs > 0 && idleMs >= timeoutMs;
25
+ return { headless, timeoutMs, idleMs, idle };
26
+ }
27
+
28
+ function buildMergedSessionRows(liveSessions, registeredSessions) {
29
+ const now = Date.now();
30
+ const regMap = new Map(registeredSessions.map((item) => [item.profileId, item]));
31
+ const rows = [];
32
+ const liveMap = new Map(liveSessions.map((item) => [item.profileId, item]));
33
+
34
+ for (const live of liveSessions) {
35
+ const reg = regMap.get(live.profileId);
36
+ const idle = computeIdleState(reg || {}, now);
37
+ rows.push({
38
+ profileId: live.profileId,
39
+ sessionId: live.session_id || live.profileId,
40
+ instanceId: reg?.instanceId || live.session_id || live.profileId,
41
+ alias: reg?.alias || null,
42
+ url: live.current_url,
43
+ mode: live.mode,
44
+ headless: idle.headless,
45
+ idleTimeoutMs: idle.timeoutMs,
46
+ idleMs: idle.idleMs,
47
+ idle: idle.idle,
48
+ live: true,
49
+ registered: !!reg,
50
+ registryStatus: reg?.status,
51
+ lastSeen: reg?.lastSeen || null,
52
+ lastActivityAt: reg?.lastActivityAt || null,
53
+ });
54
+ }
55
+
56
+ for (const reg of registeredSessions) {
57
+ if (liveMap.has(reg.profileId)) continue;
58
+ if (reg.status === 'closed') continue;
59
+ const idle = computeIdleState(reg, now);
60
+ rows.push({
61
+ profileId: reg.profileId,
62
+ sessionId: reg.sessionId || reg.profileId,
63
+ instanceId: reg.instanceId || reg.sessionId || reg.profileId,
64
+ alias: reg.alias || null,
65
+ url: reg.url || null,
66
+ mode: reg.mode || null,
67
+ headless: idle.headless,
68
+ idleTimeoutMs: idle.timeoutMs,
69
+ idleMs: idle.idleMs,
70
+ idle: idle.idle,
71
+ live: false,
72
+ orphaned: true,
73
+ needsRecovery: reg.status === 'active',
74
+ registered: true,
75
+ registryStatus: reg.status,
76
+ lastSeen: reg.lastSeen || null,
77
+ lastActivityAt: reg.lastActivityAt || null,
78
+ });
79
+ }
80
+ return rows;
81
+ }
82
+
15
83
  export async function handleCleanupCommand(args) {
16
84
  const sub = args[1];
17
85
 
@@ -158,38 +226,7 @@ export async function handleSessionsCommand(args) {
158
226
  } catch {}
159
227
  }
160
228
 
161
- // Build a map of live sessions
162
- const liveMap = new Map(liveSessions.map(s => [s.profileId, s]));
163
-
164
- // Merge live and registered sessions
165
- const merged = [];
166
-
167
- // Add all live sessions
168
- for (const live of liveSessions) {
169
- const reg = getSessionInfo(live.profileId);
170
- merged.push({
171
- profileId: live.profileId,
172
- sessionId: live.session_id || live.profileId,
173
- url: live.current_url,
174
- mode: live.mode,
175
- live: true,
176
- registered: !!reg,
177
- registryStatus: reg?.status,
178
- lastSeen: reg?.lastSeen,
179
- });
180
- }
181
-
182
- // Add registered sessions that are not live (orphaned)
183
- for (const reg of registeredSessions) {
184
- if (!liveMap.has(reg.profileId) && reg.status !== 'closed') {
185
- merged.push({
186
- ...reg,
187
- live: false,
188
- orphaned: true,
189
- needsRecovery: reg.status === 'active',
190
- });
191
- }
192
- }
229
+ const merged = buildMergedSessionRows(liveSessions, registeredSessions);
193
230
 
194
231
  console.log(JSON.stringify({
195
232
  ok: true,
@@ -202,6 +239,10 @@ export async function handleSessionsCommand(args) {
202
239
  }, null, 2));
203
240
  }
204
241
 
242
+ export async function handleInstancesCommand(args) {
243
+ await handleSessionsCommand(args);
244
+ }
245
+
205
246
  export async function handleRecoverCommand(args) {
206
247
  const profileId = resolveProfileId(args, 1, getDefaultProfile);
207
248
  if (!profileId) throw new Error('Usage: camo recover [profileId]');
@@ -33,7 +33,20 @@ export function buildSelectorClickScript({ selector, highlight }) {
33
33
  }
34
34
  el.scrollIntoView({ behavior: 'smooth', block: 'center' });
35
35
  await new Promise((r) => setTimeout(r, 150));
36
- el.click();
36
+ if (el instanceof HTMLElement) {
37
+ try { el.focus({ preventScroll: true }); } catch {}
38
+ const common = { bubbles: true, cancelable: true, view: window };
39
+ try {
40
+ if (typeof PointerEvent === 'function') {
41
+ el.dispatchEvent(new PointerEvent('pointerdown', { ...common, pointerType: 'mouse', button: 0 }));
42
+ el.dispatchEvent(new PointerEvent('pointerup', { ...common, pointerType: 'mouse', button: 0 }));
43
+ }
44
+ } catch {}
45
+ try { el.dispatchEvent(new MouseEvent('mousedown', { ...common, button: 0 })); } catch {}
46
+ try { el.dispatchEvent(new MouseEvent('mouseup', { ...common, button: 0 })); } catch {}
47
+ }
48
+ if (typeof el.click === 'function') el.click();
49
+ else el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window, button: 0 }));
37
50
  if (${highlightLiteral} && el instanceof HTMLElement) {
38
51
  setTimeout(() => { el.style.outline = restoreOutline; }, 260);
39
52
  }
@@ -56,9 +69,63 @@ export function buildSelectorTypeScript({ selector, highlight, text }) {
56
69
  }
57
70
  el.scrollIntoView({ behavior: 'smooth', block: 'center' });
58
71
  await new Promise((r) => setTimeout(r, 150));
59
- el.focus();
60
- el.value = ${textLiteral};
61
- el.dispatchEvent(new Event('input', { bubbles: true }));
72
+ if (el instanceof HTMLElement) {
73
+ try { el.focus({ preventScroll: true }); } catch {}
74
+ if (typeof el.click === 'function') el.click();
75
+ }
76
+ const value = ${textLiteral};
77
+ const fireInputEvent = (target, name, init) => {
78
+ try {
79
+ if (typeof InputEvent === 'function') {
80
+ target.dispatchEvent(new InputEvent(name, init));
81
+ return;
82
+ }
83
+ } catch {}
84
+ target.dispatchEvent(new Event(name, { bubbles: true, cancelable: init?.cancelable === true }));
85
+ };
86
+ const assignControlValue = (target, next) => {
87
+ if (target instanceof HTMLInputElement) {
88
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
89
+ if (setter) setter.call(target, next);
90
+ else target.value = next;
91
+ if (typeof target.setSelectionRange === 'function') {
92
+ const cursor = String(next).length;
93
+ try { target.setSelectionRange(cursor, cursor); } catch {}
94
+ }
95
+ return true;
96
+ }
97
+ if (target instanceof HTMLTextAreaElement) {
98
+ const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
99
+ if (setter) setter.call(target, next);
100
+ else target.value = next;
101
+ if (typeof target.setSelectionRange === 'function') {
102
+ const cursor = String(next).length;
103
+ try { target.setSelectionRange(cursor, cursor); } catch {}
104
+ }
105
+ return true;
106
+ }
107
+ return false;
108
+ };
109
+ const editableAssigned = assignControlValue(el, value);
110
+ if (!editableAssigned) {
111
+ if (el instanceof HTMLElement && el.isContentEditable) {
112
+ el.textContent = value;
113
+ } else {
114
+ throw new Error('Element not editable: ' + ${selectorLiteral});
115
+ }
116
+ }
117
+ fireInputEvent(el, 'beforeinput', {
118
+ bubbles: true,
119
+ cancelable: true,
120
+ data: value,
121
+ inputType: 'insertText',
122
+ });
123
+ fireInputEvent(el, 'input', {
124
+ bubbles: true,
125
+ cancelable: false,
126
+ data: value,
127
+ inputType: 'insertText',
128
+ });
62
129
  el.dispatchEvent(new Event('change', { bubbles: true }));
63
130
  if (${highlightLiteral} && el instanceof HTMLElement) {
64
131
  setTimeout(() => { el.style.outline = restoreOutline; }, 260);
@@ -6,6 +6,7 @@
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import os from 'node:os';
9
+ import crypto from 'node:crypto';
9
10
 
10
11
  const SESSION_DIR = path.join(os.homedir(), '.webauto', 'sessions');
11
12
 
@@ -15,6 +16,25 @@ function ensureSessionDir() {
15
16
  }
16
17
  }
17
18
 
19
+ function normalizeAlias(value) {
20
+ const text = String(value || '').trim();
21
+ if (!text) return null;
22
+ if (!/^[a-zA-Z0-9._-]+$/.test(text)) {
23
+ throw new Error('Invalid alias. Use only letters, numbers, dot, underscore, dash.');
24
+ }
25
+ return text.slice(0, 64);
26
+ }
27
+
28
+ function normalizeTimeoutMs(value) {
29
+ const ms = Number(value);
30
+ if (!Number.isFinite(ms) || ms < 0) return null;
31
+ return Math.floor(ms);
32
+ }
33
+
34
+ function generateInstanceId() {
35
+ return `inst_${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`;
36
+ }
37
+
18
38
  export function getSessionFile(profileId) {
19
39
  return path.join(SESSION_DIR, `${profileId}.json`);
20
40
  }
@@ -22,14 +42,24 @@ export function getSessionFile(profileId) {
22
42
  export function registerSession(profileId, sessionInfo = {}) {
23
43
  ensureSessionDir();
24
44
  const sessionFile = getSessionFile(profileId);
45
+ const now = Date.now();
46
+ const alias = normalizeAlias(sessionInfo.alias);
47
+ const idleTimeoutMs = normalizeTimeoutMs(sessionInfo.idleTimeoutMs);
48
+ const instanceId = String(sessionInfo.instanceId || sessionInfo.sessionId || generateInstanceId()).trim();
25
49
 
26
50
  const sessionData = {
27
51
  profileId,
28
52
  pid: process.pid,
29
- startTime: Date.now(),
30
- lastSeen: Date.now(),
53
+ instanceId,
54
+ alias,
55
+ startTime: now,
56
+ lastSeen: now,
57
+ lastActivityAt: now,
31
58
  status: 'active',
32
59
  ...sessionInfo,
60
+ instanceId,
61
+ alias,
62
+ idleTimeoutMs,
33
63
  };
34
64
 
35
65
  fs.writeFileSync(sessionFile, JSON.stringify(sessionData, null, 2));
@@ -44,12 +74,30 @@ export function updateSession(profileId, updates = {}) {
44
74
 
45
75
  try {
46
76
  const existing = JSON.parse(fs.readFileSync(sessionFile, 'utf-8'));
77
+ const now = Date.now();
78
+ const mergedAlias = Object.prototype.hasOwnProperty.call(updates, 'alias')
79
+ ? normalizeAlias(updates.alias)
80
+ : normalizeAlias(existing.alias);
81
+ const mergedIdleTimeoutMs = Object.prototype.hasOwnProperty.call(updates, 'idleTimeoutMs')
82
+ ? normalizeTimeoutMs(updates.idleTimeoutMs)
83
+ : normalizeTimeoutMs(existing.idleTimeoutMs);
84
+ const touchActivity = updates.touchActivity === true;
85
+ const nextActivityAt = touchActivity
86
+ ? now
87
+ : (Object.prototype.hasOwnProperty.call(updates, 'lastActivityAt')
88
+ ? Number(updates.lastActivityAt) || now
89
+ : Number(existing.lastActivityAt) || now);
47
90
  const updated = {
48
91
  ...existing,
49
92
  ...updates,
50
- lastSeen: Date.now(),
93
+ instanceId: String(updates.instanceId || existing.instanceId || updates.sessionId || existing.sessionId || generateInstanceId()).trim(),
94
+ alias: mergedAlias,
95
+ idleTimeoutMs: mergedIdleTimeoutMs,
96
+ lastSeen: now,
97
+ lastActivityAt: nextActivityAt,
51
98
  profileId,
52
99
  };
100
+ delete updated.touchActivity;
53
101
  fs.writeFileSync(sessionFile, JSON.stringify(updated, null, 2));
54
102
  return updated;
55
103
  } catch {
@@ -93,6 +141,54 @@ export function listRegisteredSessions() {
93
141
  return sessions;
94
142
  }
95
143
 
144
+ export function findSessionByAlias(alias) {
145
+ const target = normalizeAlias(alias);
146
+ if (!target) return null;
147
+ return listRegisteredSessions().find((item) => normalizeAlias(item?.alias) === target) || null;
148
+ }
149
+
150
+ export function findSessionByInstanceId(instanceId) {
151
+ const target = String(instanceId || '').trim();
152
+ if (!target) return null;
153
+ return listRegisteredSessions().find((item) => String(item?.instanceId || '').trim() === target) || null;
154
+ }
155
+
156
+ export function resolveSessionTarget(target) {
157
+ const value = String(target || '').trim();
158
+ if (!value) return null;
159
+ const sessions = listRegisteredSessions();
160
+ const byProfile = sessions.find((item) => String(item?.profileId || '').trim() === value);
161
+ if (byProfile) return { profileId: byProfile.profileId, reason: 'profile', session: byProfile };
162
+ const byInstanceId = sessions.find((item) => String(item?.instanceId || '').trim() === value);
163
+ if (byInstanceId) return { profileId: byInstanceId.profileId, reason: 'instanceId', session: byInstanceId };
164
+ const byAlias = sessions.find((item) => normalizeAlias(item?.alias) === normalizeAlias(value));
165
+ if (byAlias) return { profileId: byAlias.profileId, reason: 'alias', session: byAlias };
166
+ return null;
167
+ }
168
+
169
+ export function isSessionAliasTaken(alias, exceptProfileId = '') {
170
+ const target = normalizeAlias(alias);
171
+ if (!target) return false;
172
+ const except = String(exceptProfileId || '').trim();
173
+ return listRegisteredSessions().some((item) => {
174
+ if (!item) return false;
175
+ if (except && String(item.profileId || '').trim() === except) return false;
176
+ if (String(item.status || '').trim() !== 'active') return false;
177
+ return normalizeAlias(item.alias) === target;
178
+ });
179
+ }
180
+
181
+ export function touchSessionActivity(profileId, updates = {}) {
182
+ const id = String(profileId || '').trim();
183
+ if (!id) return null;
184
+ const existing = getSessionInfo(id);
185
+ if (!existing) return null;
186
+ return updateSession(id, {
187
+ ...updates,
188
+ touchActivity: true,
189
+ });
190
+ }
191
+
96
192
  export function markSessionReconnecting(profileId) {
97
193
  return updateSession(profileId, { status: 'reconnecting', reconnectAttempt: Date.now() });
98
194
  }
@@ -9,6 +9,7 @@ import { releaseLock } from './lock.mjs';
9
9
  import { getSessionInfo, markSessionClosed } from './session-registry.mjs';
10
10
 
11
11
  const WATCHDOG_DIR = path.join(os.homedir(), '.webauto', 'run', 'camo-watchdogs');
12
+ const DEFAULT_HEADLESS_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
12
13
 
13
14
  function ensureWatchdogDir() {
14
15
  if (!fs.existsSync(WATCHDOG_DIR)) {
@@ -102,9 +103,11 @@ async function syncViewportWithWindow(profileId, tolerancePx = 3) {
102
103
  return true;
103
104
  }
104
105
 
105
- function shouldExitMonitor(profileId) {
106
- const info = getSessionInfo(profileId);
107
- return !info || info.status !== 'active';
106
+ function resolveIdleTimeoutMs(sessionInfo) {
107
+ if (!sessionInfo || sessionInfo.headless !== true) return 0;
108
+ const raw = Number(sessionInfo.idleTimeoutMs);
109
+ if (Number.isFinite(raw) && raw >= 0) return Math.floor(raw);
110
+ return DEFAULT_HEADLESS_IDLE_TIMEOUT_MS;
108
111
  }
109
112
 
110
113
  async function cleanupSession(profileId) {
@@ -126,7 +129,18 @@ async function runMonitor(profileId, options = {}) {
126
129
  let blankOnlyStreak = 0;
127
130
 
128
131
  while (true) {
129
- if (shouldExitMonitor(profileId)) return;
132
+ const localInfo = getSessionInfo(profileId);
133
+ if (!localInfo || localInfo.status !== 'active') return;
134
+
135
+ const idleTimeoutMs = resolveIdleTimeoutMs(localInfo);
136
+ if (idleTimeoutMs > 0) {
137
+ const lastActivityAt = Number(localInfo.lastActivityAt || localInfo.lastSeen || localInfo.startTime || Date.now());
138
+ const idleMs = Date.now() - lastActivityAt;
139
+ if (idleMs >= idleTimeoutMs) {
140
+ await cleanupSession(profileId);
141
+ return;
142
+ }
143
+ }
130
144
 
131
145
  let sessions = [];
132
146
  try {
@@ -4,6 +4,14 @@ import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import os from 'node:os';
6
6
  import { BROWSER_SERVICE_URL, loadConfig, setRepoRoot } from './config.mjs';
7
+ import { touchSessionActivity } from '../lifecycle/session-registry.mjs';
8
+
9
+ function shouldTrackSessionActivity(action, payload) {
10
+ const profileId = String(payload?.profileId || '').trim();
11
+ if (!profileId) return false;
12
+ if (action === 'getStatus' || action === 'service:shutdown' || action === 'stop') return false;
13
+ return true;
14
+ }
7
15
 
8
16
  export async function callAPI(action, payload = {}) {
9
17
  const r = await fetch(`${BROWSER_SERVICE_URL}/command`, {
@@ -21,6 +29,12 @@ export async function callAPI(action, payload = {}) {
21
29
  }
22
30
 
23
31
  if (!r.ok) throw new Error(body?.error || `HTTP ${r.status}`);
32
+ if (shouldTrackSessionActivity(action, payload)) {
33
+ touchSessionActivity(payload.profileId, {
34
+ lastAction: String(action || '').trim() || null,
35
+ lastActionAt: Date.now(),
36
+ });
37
+ }
24
38
  return body;
25
39
  }
26
40
 
@@ -24,12 +24,17 @@ CONFIG:
24
24
 
25
25
  BROWSER CONTROL:
26
26
  init Ensure camoufox + ensure browser-service daemon
27
- start [profileId] [--url <url>] [--headless] [--width <w> --height <h>]
27
+ start [profileId] [--url <url>] [--headless] [--devtools] [--alias <name>] [--idle-timeout <duration>] [--width <w> --height <h>]
28
28
  stop [profileId]
29
+ stop --id <instanceId> Stop by instance id
30
+ stop --alias <alias> Stop by alias
31
+ stop idle Stop all idle sessions
32
+ stop all Stop all sessions
29
33
  status [profileId]
30
34
  list Alias of status
31
35
 
32
36
  LIFECYCLE & CLEANUP:
37
+ instances List global camoufox instances (live + registered + idle state)
33
38
  sessions List active browser sessions
34
39
  cleanup [profileId] Cleanup session (release lock + stop)
35
40
  cleanup all Cleanup all active sessions
@@ -90,8 +95,14 @@ EXAMPLES:
90
95
  camo create fingerprint --os windows --region uk
91
96
  camo profile create myprofile
92
97
  camo profile default myprofile
93
- camo start --url https://example.com
98
+ camo start --url https://example.com --alias main
99
+ camo start worker-1 --headless --alias shard1 --idle-timeout 45m
100
+ camo start worker-1 --devtools
94
101
  camo start myprofile --width 1920 --height 1020
102
+ camo stop --id inst_xxxxxxxx
103
+ camo stop --alias shard1
104
+ camo stop idle
105
+ camo close all
95
106
  camo goto https://www.xiaohongshu.com
96
107
  camo scroll --down --amount 500
97
108
  camo click "#search-input"
@@ -109,6 +120,7 @@ EXAMPLES:
109
120
  camo mouse wheel --deltay -300
110
121
  camo system display
111
122
  camo sessions
123
+ camo instances
112
124
  camo cleanup myprofile
113
125
  camo force-stop myprofile
114
126
  camo lock list