@web-auto/camo 0.1.2

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/src/cli.mjs ADDED
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env node
2
+ import { fileURLToPath } from 'node:url';
3
+ import path from 'node:path';
4
+ import { listProfiles, getDefaultProfile, loadConfig, hasStartScript, setRepoRoot } from './utils/config.mjs';
5
+ import { ensureCamoufox, ensureBrowserService } from './utils/browser-service.mjs';
6
+ import { printHelp, printProfilesAndHint } from './utils/help.mjs';
7
+ import { handleProfileCommand } from './commands/profile.mjs';
8
+ import { handleInitCommand } from './commands/init.mjs';
9
+ import { handleCreateCommand } from './commands/create.mjs';
10
+ import { handleCookiesCommand } from './commands/cookies.mjs';
11
+ import { handleWindowCommand } from './commands/window.mjs';
12
+ import { handleMouseCommand } from './commands/mouse.mjs';
13
+ import { handleSystemCommand } from './commands/system.mjs';
14
+ import {
15
+ handleStartCommand, handleStopCommand, handleStatusCommand,
16
+ handleGotoCommand, handleBackCommand, handleScreenshotCommand,
17
+ handleScrollCommand, handleClickCommand, handleTypeCommand,
18
+ handleHighlightCommand, handleClearHighlightCommand, handleViewportCommand,
19
+ handleNewPageCommand, handleClosePageCommand, handleSwitchPageCommand,
20
+ handleListPagesCommand, handleShutdownCommand
21
+ } from './commands/browser.mjs';
22
+ import {
23
+ handleCleanupCommand, handleForceStopCommand, handleLockCommand,
24
+ handleUnlockCommand, handleSessionsCommand
25
+ } from './commands/lifecycle.mjs';
26
+
27
+ const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
28
+ const START_SCRIPT_REL = path.join('runtime', 'infra', 'utils', 'scripts', 'service', 'start-browser-service.mjs');
29
+
30
+ async function handleConfigCommand(args) {
31
+ const sub = args[1];
32
+ if (sub !== 'repo-root') {
33
+ throw new Error('Usage: camo config repo-root [path]');
34
+ }
35
+ const repoRoot = args[2];
36
+ if (!repoRoot) {
37
+ console.log(JSON.stringify({ ok: true, repoRoot: loadConfig().repoRoot }, null, 2));
38
+ return;
39
+ }
40
+ const resolved = path.resolve(repoRoot);
41
+ if (!hasStartScript(resolved)) {
42
+ throw new Error(`Invalid repo root: ${resolved} (missing ${START_SCRIPT_REL})`);
43
+ }
44
+ setRepoRoot(resolved);
45
+ console.log(JSON.stringify({ ok: true, repoRoot: resolved }, null, 2));
46
+ }
47
+
48
+ async function main() {
49
+ const args = process.argv.slice(2);
50
+ const cmd = args[0];
51
+
52
+ if (!cmd) {
53
+ printProfilesAndHint(listProfiles, getDefaultProfile);
54
+ return;
55
+ }
56
+
57
+ if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
58
+ printHelp();
59
+ return;
60
+ }
61
+
62
+ if (cmd === 'profiles') {
63
+ const profiles = listProfiles();
64
+ const defaultProfile = getDefaultProfile();
65
+ console.log(JSON.stringify({ ok: true, profiles, defaultProfile, count: profiles.length }, null, 2));
66
+ return;
67
+ }
68
+
69
+ if (cmd === 'profile') {
70
+ await handleProfileCommand(args);
71
+ return;
72
+ }
73
+
74
+ if (cmd === 'config') {
75
+ await handleConfigCommand(args);
76
+ return;
77
+ }
78
+
79
+ if (cmd === 'init') {
80
+ await handleInitCommand(args);
81
+ return;
82
+ }
83
+
84
+ if (cmd === 'create') {
85
+ await handleCreateCommand(args);
86
+ return;
87
+ }
88
+
89
+ // Lifecycle commands
90
+ if (cmd === 'cleanup') {
91
+ await handleCleanupCommand(args);
92
+ return;
93
+ }
94
+
95
+ if (cmd === 'force-stop') {
96
+ await handleForceStopCommand(args);
97
+ return;
98
+ }
99
+
100
+ if (cmd === 'lock') {
101
+ await handleLockCommand(args);
102
+ return;
103
+ }
104
+
105
+ if (cmd === 'unlock') {
106
+ await handleUnlockCommand(args);
107
+ return;
108
+ }
109
+
110
+ if (cmd === 'sessions') {
111
+ await handleSessionsCommand(args);
112
+ return;
113
+ }
114
+
115
+ const serviceCommands = new Set([
116
+ 'start', 'stop', 'close', 'status', 'list', 'goto', 'navigate', 'back', 'screenshot',
117
+ 'new-page', 'close-page', 'switch-page', 'list-pages', 'shutdown',
118
+ 'scroll', 'click', 'type', 'highlight', 'clear-highlight', 'viewport',
119
+ 'cookies', 'window', 'mouse', 'system',
120
+ ]);
121
+
122
+ if (!serviceCommands.has(cmd)) {
123
+ throw new Error(`Unknown command: ${cmd}`);
124
+ }
125
+
126
+ switch (cmd) {
127
+ case 'start':
128
+ await handleStartCommand(args);
129
+ break;
130
+ case 'stop':
131
+ case 'close':
132
+ await handleStopCommand(args);
133
+ break;
134
+ case 'status':
135
+ case 'list':
136
+ await handleStatusCommand(args);
137
+ break;
138
+ case 'goto':
139
+ case 'navigate':
140
+ await handleGotoCommand(args);
141
+ break;
142
+ case 'back':
143
+ await handleBackCommand(args);
144
+ break;
145
+ case 'screenshot':
146
+ await handleScreenshotCommand(args);
147
+ break;
148
+ case 'scroll':
149
+ await handleScrollCommand(args);
150
+ break;
151
+ case 'click':
152
+ await handleClickCommand(args);
153
+ break;
154
+ case 'type':
155
+ await handleTypeCommand(args);
156
+ break;
157
+ case 'highlight':
158
+ await handleHighlightCommand(args);
159
+ break;
160
+ case 'clear-highlight':
161
+ await handleClearHighlightCommand(args);
162
+ break;
163
+ case 'viewport':
164
+ await handleViewportCommand(args);
165
+ break;
166
+ case 'new-page':
167
+ await handleNewPageCommand(args);
168
+ break;
169
+ case 'close-page':
170
+ await handleClosePageCommand(args);
171
+ break;
172
+ case 'switch-page':
173
+ await handleSwitchPageCommand(args);
174
+ break;
175
+ case 'list-pages':
176
+ await handleListPagesCommand(args);
177
+ break;
178
+ case 'shutdown':
179
+ await handleShutdownCommand();
180
+ break;
181
+ case 'cookies':
182
+ await handleCookiesCommand(args);
183
+ break;
184
+ case 'window':
185
+ await handleWindowCommand(args);
186
+ break;
187
+ case 'mouse':
188
+ await handleMouseCommand(args);
189
+ break;
190
+ case 'system':
191
+ await handleSystemCommand(args);
192
+ break;
193
+ }
194
+ }
195
+
196
+ main().catch((err) => {
197
+ console.error(`Error: ${err?.message || String(err)}`);
198
+ process.exit(1);
199
+ });
@@ -0,0 +1,462 @@
1
+ import fs from 'node:fs';
2
+ import { listProfiles, getDefaultProfile } from '../utils/config.mjs';
3
+ import { callAPI, ensureCamoufox, ensureBrowserService, getSessionByProfile, checkBrowserService } from '../utils/browser-service.mjs';
4
+ import { resolveProfileId, ensureUrlScheme, looksLikeUrlToken, getPositionals } from '../utils/args.mjs';
5
+ import { acquireLock, releaseLock, isLocked, getLockInfo, cleanupStaleLocks } from '../lifecycle/lock.mjs';
6
+ import { registerSession, updateSession, getSessionInfo, unregisterSession, listRegisteredSessions, markSessionClosed, cleanupStaleSessions, recoverSession } from '../lifecycle/session-registry.mjs';
7
+
8
+ export async function handleStartCommand(args) {
9
+ ensureCamoufox();
10
+ await ensureBrowserService();
11
+ cleanupStaleLocks();
12
+ cleanupStaleSessions();
13
+
14
+ const urlIdx = args.indexOf('--url');
15
+ const explicitUrl = urlIdx >= 0 ? args[urlIdx + 1] : undefined;
16
+ const profileSet = new Set(listProfiles());
17
+ let implicitUrl;
18
+
19
+ let profileId = null;
20
+ for (let i = 1; i < args.length; i++) {
21
+ const arg = args[i];
22
+ if (arg === '--url') { i++; continue; }
23
+ if (arg === '--headless') continue;
24
+ if (arg.startsWith('--')) continue;
25
+
26
+ if (looksLikeUrlToken(arg) && !profileSet.has(arg)) {
27
+ implicitUrl = arg;
28
+ continue;
29
+ }
30
+
31
+ profileId = arg;
32
+ break;
33
+ }
34
+
35
+ if (!profileId) {
36
+ profileId = getDefaultProfile();
37
+ if (!profileId) {
38
+ throw new Error('No default profile set. Run: camo profile default <profileId>');
39
+ }
40
+ }
41
+
42
+ // Check for existing session in browser service
43
+ const existing = await getSessionByProfile(profileId);
44
+ if (existing) {
45
+ // Session exists in browser service - update registry and lock
46
+ acquireLock(profileId, { sessionId: existing.session_id || existing.profileId });
47
+ registerSession(profileId, {
48
+ sessionId: existing.session_id || existing.profileId,
49
+ url: existing.current_url,
50
+ mode: existing.mode,
51
+ });
52
+ console.log(JSON.stringify({
53
+ ok: true,
54
+ sessionId: existing.session_id || existing.profileId,
55
+ profileId,
56
+ message: 'Session already running',
57
+ url: existing.current_url,
58
+ }, null, 2));
59
+ return;
60
+ }
61
+
62
+ // No session in browser service - check registry for recovery
63
+ const registryInfo = getSessionInfo(profileId);
64
+ if (registryInfo && registryInfo.status === 'active') {
65
+ // Session was active but browser service doesn't have it
66
+ // This means service was restarted - clean up and start fresh
67
+ unregisterSession(profileId);
68
+ releaseLock(profileId);
69
+ }
70
+
71
+ const headless = args.includes('--headless');
72
+ const targetUrl = explicitUrl || implicitUrl;
73
+ const result = await callAPI('start', {
74
+ profileId,
75
+ url: targetUrl ? ensureUrlScheme(targetUrl) : undefined,
76
+ headless,
77
+ });
78
+
79
+ if (result?.ok) {
80
+ const sessionId = result.sessionId || result.profileId || profileId;
81
+ acquireLock(profileId, { sessionId });
82
+ registerSession(profileId, {
83
+ sessionId,
84
+ url: targetUrl,
85
+ headless,
86
+ });
87
+ }
88
+ console.log(JSON.stringify(result, null, 2));
89
+ }
90
+
91
+ export async function handleStopCommand(args) {
92
+ await ensureBrowserService();
93
+ const profileId = resolveProfileId(args, 1, getDefaultProfile);
94
+ if (!profileId) throw new Error('Usage: camo stop [profileId]');
95
+
96
+ const result = await callAPI('stop', { profileId });
97
+ releaseLock(profileId);
98
+ markSessionClosed(profileId);
99
+ console.log(JSON.stringify(result, null, 2));
100
+ }
101
+
102
+ export async function handleStatusCommand(args) {
103
+ await ensureBrowserService();
104
+ const result = await callAPI('getStatus', {});
105
+ const profileId = args[1];
106
+ if (profileId && args[0] === 'status') {
107
+ const session = result?.sessions?.find((s) => s.profileId === profileId) || null;
108
+ console.log(JSON.stringify({ ok: true, session }, null, 2));
109
+ return;
110
+ }
111
+ console.log(JSON.stringify(result, null, 2));
112
+ }
113
+
114
+ export async function handleGotoCommand(args) {
115
+ await ensureBrowserService();
116
+ const positionals = getPositionals(args);
117
+
118
+ let profileId;
119
+ let url;
120
+
121
+ if (positionals.length === 1) {
122
+ profileId = getDefaultProfile();
123
+ url = positionals[0];
124
+ } else {
125
+ profileId = resolveProfileId(positionals, 0, getDefaultProfile);
126
+ url = positionals[1];
127
+ }
128
+
129
+ if (!profileId) throw new Error('Usage: camo goto [profileId] <url> (or set default profile first)');
130
+ if (!url) throw new Error('Usage: camo goto [profileId] <url>');
131
+
132
+ const result = await callAPI('goto', { profileId, url: ensureUrlScheme(url) });
133
+ updateSession(profileId, { url: ensureUrlScheme(url), lastSeen: Date.now() });
134
+ console.log(JSON.stringify(result, null, 2));
135
+ }
136
+
137
+ export async function handleBackCommand(args) {
138
+ await ensureBrowserService();
139
+ const profileId = resolveProfileId(args, 1, getDefaultProfile);
140
+ if (!profileId) throw new Error('Usage: camo back [profileId] (or set default profile first)');
141
+ const result = await callAPI('page:back', { profileId });
142
+ console.log(JSON.stringify(result, null, 2));
143
+ }
144
+
145
+ export async function handleScreenshotCommand(args) {
146
+ await ensureBrowserService();
147
+ const fullPage = args.includes('--full');
148
+ const outputIdx = args.indexOf('--output');
149
+ const output = outputIdx >= 0 ? args[outputIdx + 1] : null;
150
+
151
+ let profileId = null;
152
+ for (let i = 1; i < args.length; i++) {
153
+ const arg = args[i];
154
+ if (arg === '--full') continue;
155
+ if (arg === '--output') { i++; continue; }
156
+ if (arg.startsWith('--')) continue;
157
+ profileId = arg;
158
+ break;
159
+ }
160
+
161
+ if (!profileId) profileId = getDefaultProfile();
162
+ if (!profileId) throw new Error('Usage: camo screenshot [profileId] [--output <file>] [--full]');
163
+
164
+ const result = await callAPI('screenshot', { profileId, fullPage });
165
+
166
+ if (output && result?.data) {
167
+ fs.writeFileSync(output, Buffer.from(result.data, 'base64'));
168
+ console.log(`Screenshot saved to ${output}`);
169
+ return;
170
+ }
171
+
172
+ console.log(JSON.stringify(result, null, 2));
173
+ }
174
+
175
+ export async function handleScrollCommand(args) {
176
+ await ensureBrowserService();
177
+ const directionFlags = new Set(['--up', '--down', '--left', '--right']);
178
+ const isFlag = (arg) => arg?.startsWith('--');
179
+
180
+ let profileId = null;
181
+ for (let i = 1; i < args.length; i++) {
182
+ const arg = args[i];
183
+ if (directionFlags.has(arg)) continue;
184
+ if (arg === '--amount') { i++; continue; }
185
+ if (isFlag(arg)) continue;
186
+ profileId = arg;
187
+ break;
188
+ }
189
+ if (!profileId) profileId = getDefaultProfile();
190
+ if (!profileId) throw new Error('Usage: camo scroll [profileId] [--down|--up|--left|--right] [--amount <px>]');
191
+
192
+ const direction = args.includes('--up') ? 'up' : args.includes('--left') ? 'left' : args.includes('--right') ? 'right' : 'down';
193
+ const amountIdx = args.indexOf('--amount');
194
+ const amount = amountIdx >= 0 ? Number(args[amountIdx + 1]) || 300 : 300;
195
+
196
+ const deltaX = direction === 'left' ? -amount : direction === 'right' ? amount : 0;
197
+ const deltaY = direction === 'up' ? -amount : direction === 'down' ? amount : 0;
198
+ const result = await callAPI('mouse:wheel', { profileId, deltaX, deltaY });
199
+ console.log(JSON.stringify(result, null, 2));
200
+ }
201
+
202
+ export async function handleClickCommand(args) {
203
+ await ensureBrowserService();
204
+ const positionals = getPositionals(args);
205
+ let profileId;
206
+ let selector;
207
+
208
+ if (positionals.length === 1) {
209
+ profileId = getDefaultProfile();
210
+ selector = positionals[0];
211
+ } else {
212
+ profileId = positionals[0];
213
+ selector = positionals[1];
214
+ }
215
+
216
+ if (!profileId) throw new Error('Usage: camo click [profileId] <selector>');
217
+ if (!selector) throw new Error('Usage: camo click [profileId] <selector>');
218
+
219
+ const result = await callAPI('evaluate', {
220
+ profileId,
221
+ script: `(async () => {
222
+ const el = document.querySelector(${JSON.stringify(selector)});
223
+ if (!el) throw new Error('Element not found: ' + ${JSON.stringify(selector)});
224
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
225
+ await new Promise(r => setTimeout(r, 200));
226
+ el.click();
227
+ return { clicked: true, selector: ${JSON.stringify(selector)} };
228
+ })()`
229
+ });
230
+ console.log(JSON.stringify(result, null, 2));
231
+ }
232
+
233
+ export async function handleTypeCommand(args) {
234
+ await ensureBrowserService();
235
+ const positionals = getPositionals(args);
236
+ let profileId;
237
+ let selector;
238
+ let text;
239
+
240
+ if (positionals.length === 2) {
241
+ profileId = getDefaultProfile();
242
+ selector = positionals[0];
243
+ text = positionals[1];
244
+ } else {
245
+ profileId = positionals[0];
246
+ selector = positionals[1];
247
+ text = positionals[2];
248
+ }
249
+
250
+ if (!profileId) throw new Error('Usage: camo type [profileId] <selector> <text>');
251
+ if (!selector || text === undefined) throw new Error('Usage: camo type [profileId] <selector> <text>');
252
+
253
+ const result = await callAPI('evaluate', {
254
+ profileId,
255
+ script: `(async () => {
256
+ const el = document.querySelector(${JSON.stringify(selector)});
257
+ if (!el) throw new Error('Element not found: ' + ${JSON.stringify(selector)});
258
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
259
+ await new Promise(r => setTimeout(r, 200));
260
+ el.focus();
261
+ el.value = '';
262
+ el.value = ${JSON.stringify(text)};
263
+ el.dispatchEvent(new Event('input', { bubbles: true }));
264
+ el.dispatchEvent(new Event('change', { bubbles: true }));
265
+ return { typed: true, selector: ${JSON.stringify(selector)}, length: ${text.length} };
266
+ })()`
267
+ });
268
+ console.log(JSON.stringify(result, null, 2));
269
+ }
270
+
271
+ export async function handleHighlightCommand(args) {
272
+ await ensureBrowserService();
273
+ const positionals = getPositionals(args);
274
+ let profileId;
275
+ let selector;
276
+
277
+ if (positionals.length === 1) {
278
+ profileId = getDefaultProfile();
279
+ selector = positionals[0];
280
+ } else {
281
+ profileId = positionals[0];
282
+ selector = positionals[1];
283
+ }
284
+
285
+ if (!profileId) throw new Error('Usage: camo highlight [profileId] <selector>');
286
+ if (!selector) throw new Error('Usage: camo highlight [profileId] <selector>');
287
+
288
+ const result = await callAPI('evaluate', {
289
+ profileId,
290
+ script: `(() => {
291
+ const el = document.querySelector(${JSON.stringify(selector)});
292
+ if (!el) throw new Error('Element not found: ' + ${JSON.stringify(selector)});
293
+ const prev = el.style.outline;
294
+ el.style.outline = '3px solid #ff4444';
295
+ setTimeout(() => { el.style.outline = prev; }, 2000);
296
+ const rect = el.getBoundingClientRect();
297
+ return { highlighted: true, selector: ${JSON.stringify(selector)}, rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } };
298
+ })()`
299
+ });
300
+ console.log(JSON.stringify(result, null, 2));
301
+ }
302
+
303
+ export async function handleClearHighlightCommand(args) {
304
+ await ensureBrowserService();
305
+ const profileId = resolveProfileId(args, 1, getDefaultProfile);
306
+ if (!profileId) throw new Error('Usage: camo clear-highlight [profileId]');
307
+
308
+ const result = await callAPI('evaluate', {
309
+ profileId,
310
+ script: `(() => {
311
+ const overlay = document.getElementById('webauto-highlight-overlay');
312
+ if (overlay) overlay.remove();
313
+ document.querySelectorAll('[data-webauto-highlight]').forEach(el => {
314
+ el.style.outline = el.dataset.webautoHighlight || '';
315
+ delete el.dataset.webautoHighlight;
316
+ });
317
+ return { cleared: true };
318
+ })()`
319
+ });
320
+ console.log(JSON.stringify(result, null, 2));
321
+ }
322
+
323
+ export async function handleViewportCommand(args) {
324
+ await ensureBrowserService();
325
+ const profileId = resolveProfileId(args, 1, getDefaultProfile);
326
+ if (!profileId) throw new Error('Usage: camo viewport [profileId] --width <w> --height <h>');
327
+
328
+ const widthIdx = args.indexOf('--width');
329
+ const heightIdx = args.indexOf('--height');
330
+ const width = widthIdx >= 0 ? Number(args[widthIdx + 1]) : 1280;
331
+ const height = heightIdx >= 0 ? Number(args[heightIdx + 1]) : 800;
332
+
333
+ if (!Number.isFinite(width) || !Number.isFinite(height)) {
334
+ throw new Error('Usage: camo viewport [profileId] --width <w> --height <h>');
335
+ }
336
+
337
+ const result = await callAPI('page:setViewport', { profileId, width, height });
338
+ console.log(JSON.stringify(result, null, 2));
339
+ }
340
+
341
+ export async function handleNewPageCommand(args) {
342
+ await ensureBrowserService();
343
+ const profileId = resolveProfileId(args, 1, getDefaultProfile);
344
+ if (!profileId) throw new Error('Usage: camo new-page [profileId] [--url <url>] (or set default profile first)');
345
+ const urlIdx = args.indexOf('--url');
346
+ const url = urlIdx >= 0 ? args[urlIdx + 1] : undefined;
347
+ const result = await callAPI('newPage', { profileId, ...(url ? { url: ensureUrlScheme(url) } : {}) });
348
+ console.log(JSON.stringify(result, null, 2));
349
+ }
350
+
351
+ export async function handleClosePageCommand(args) {
352
+ await ensureBrowserService();
353
+ const profileId = resolveProfileId(args, 1, getDefaultProfile);
354
+ if (!profileId) throw new Error('Usage: camo close-page [profileId] [index] (or set default profile first)');
355
+
356
+ let index;
357
+ for (let i = args.length - 1; i >= 1; i--) {
358
+ const arg = args[i];
359
+ if (arg.startsWith('--')) continue;
360
+ const num = Number(arg);
361
+ if (Number.isFinite(num)) { index = num; break; }
362
+ }
363
+
364
+ const result = await callAPI('page:close', { profileId, ...(Number.isFinite(index) ? { index } : {}) });
365
+ console.log(JSON.stringify(result, null, 2));
366
+ }
367
+
368
+ export async function handleSwitchPageCommand(args) {
369
+ await ensureBrowserService();
370
+ const profileId = resolveProfileId(args, 1, getDefaultProfile);
371
+ if (!profileId) throw new Error('Usage: camo switch-page [profileId] <index> (or set default profile first)');
372
+
373
+ let index;
374
+ for (let i = args.length - 1; i >= 1; i--) {
375
+ const arg = args[i];
376
+ if (arg.startsWith('--')) continue;
377
+ const num = Number(arg);
378
+ if (Number.isFinite(num)) { index = num; break; }
379
+ }
380
+
381
+ if (!Number.isFinite(index)) throw new Error('Usage: camo switch-page [profileId] <index>');
382
+ const result = await callAPI('page:switch', { profileId, index });
383
+ console.log(JSON.stringify(result, null, 2));
384
+ }
385
+
386
+ export async function handleListPagesCommand(args) {
387
+ await ensureBrowserService();
388
+ const profileId = resolveProfileId(args, 1, getDefaultProfile);
389
+ if (!profileId) throw new Error('Usage: camo list-pages [profileId] (or set default profile first)');
390
+ const result = await callAPI('page:list', { profileId });
391
+ console.log(JSON.stringify(result, null, 2));
392
+ }
393
+
394
+ export async function handleShutdownCommand() {
395
+ await ensureBrowserService();
396
+
397
+ // Get all active sessions
398
+ const status = await callAPI('getStatus', {});
399
+ const sessions = Array.isArray(status?.sessions) ? status.sessions : [];
400
+
401
+ // Stop each session and cleanup registry
402
+ for (const session of sessions) {
403
+ try {
404
+ await callAPI('stop', { profileId: session.profileId });
405
+ } catch {
406
+ // Best effort cleanup
407
+ }
408
+ releaseLock(session.profileId);
409
+ markSessionClosed(session.profileId);
410
+ }
411
+
412
+ // Cleanup any remaining registry entries
413
+ const registered = listRegisteredSessions();
414
+ for (const reg of registered) {
415
+ if (reg.status !== 'closed') {
416
+ markSessionClosed(reg.profileId);
417
+ releaseLock(reg.profileId);
418
+ }
419
+ }
420
+
421
+ const result = await callAPI('service:shutdown', {});
422
+ console.log(JSON.stringify(result, null, 2));
423
+ }
424
+
425
+ export async function handleSessionsCommand(args) {
426
+ const serviceUp = await checkBrowserService();
427
+ const registeredSessions = listRegisteredSessions();
428
+
429
+ let liveSessions = [];
430
+ if (serviceUp) {
431
+ try {
432
+ const status = await callAPI('getStatus', {});
433
+ liveSessions = Array.isArray(status?.sessions) ? status.sessions : [];
434
+ } catch {
435
+ // Service may have just become unavailable
436
+ }
437
+ }
438
+
439
+ // Merge live and registered sessions
440
+ const liveProfileIds = new Set(liveSessions.map(s => s.profileId));
441
+ const merged = [...liveSessions];
442
+
443
+ // Add registered sessions that are not in live sessions (need recovery)
444
+ for (const reg of registeredSessions) {
445
+ if (!liveProfileIds.has(reg.profileId) && reg.status === 'active') {
446
+ merged.push({
447
+ ...reg,
448
+ live: false,
449
+ needsRecovery: true,
450
+ });
451
+ }
452
+ }
453
+
454
+ console.log(JSON.stringify({
455
+ ok: true,
456
+ serviceUp,
457
+ sessions: merged,
458
+ count: merged.length,
459
+ registered: registeredSessions.length,
460
+ live: liveSessions.length,
461
+ }, null, 2));
462
+ }