@web-auto/camo 0.1.24 → 0.1.25

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.
@@ -37,6 +37,7 @@ export class BrowserSession {
37
37
  exitNotified = false;
38
38
  constructor(options) {
39
39
  this.options = options;
40
+ this.maxTabs = Math.max(5, Math.floor(Number(options.maxTabs ?? 5) || 5));
40
41
  const profileId = options.profileId || 'default';
41
42
  const root = resolveProfilesRoot();
42
43
  this.profileDir = path.join(root, profileId);
@@ -69,6 +70,7 @@ export class BrowserSession {
69
70
  recordLastKnownUrl: (url) => { if (url)
70
71
  this.lastKnownUrl = url; },
71
72
  isHeadless: () => this.options.headless === true,
73
+ getMaxTabs: () => this.maxTabs,
72
74
  });
73
75
  this.navigation = new BrowserSessionNavigation({
74
76
  ensurePrimaryPage: () => this.pageManager.ensurePrimaryPage(),
@@ -147,7 +149,10 @@ export class BrowserSession {
147
149
  const existing = this.context.pages();
148
150
  this.page = existing.length ? existing[0] : await this.context.newPage();
149
151
  this.setupPageHooks(this.page);
150
- this.context.on('page', (p) => this.setupPageHooks(p));
152
+ this.context.on('page', (p) => {
153
+ this.setupPageHooks(p);
154
+ this.enforceMaxTabs();
155
+ });
151
156
  if (this.viewportManager.isFollowingWindow()) {
152
157
  await this.viewportManager.refreshFromWindow(this.page).catch(() => { });
153
158
  }
@@ -158,6 +163,33 @@ export class BrowserSession {
158
163
  setupPageHooks(page) {
159
164
  this.pageHooks.setupPageHooks(page);
160
165
  }
166
+
167
+ async enforceMaxTabs() {
168
+ try {
169
+ const ctx = this.context;
170
+ if (!ctx) return;
171
+ const pages = ctx.pages().filter((p) => !p.isClosed());
172
+ if (pages.length <= this.maxTabs) return;
173
+ const activePage = this.getActivePage();
174
+ const excess = pages.length - this.maxTabs;
175
+ const closable = pages
176
+ .filter((p) => p !== activePage)
177
+ .sort((a, b) => {
178
+ const aUrl = a.url() || '';
179
+ const bUrl = b.url() || '';
180
+ const aScore = (aUrl.includes('captcha') || aUrl === 'about:blank') ? 1 : 0;
181
+ const bScore = (bUrl.includes('captcha') || bUrl === 'about:blank') ? 1 : 0;
182
+ if (aScore !== bScore) return bScore - aScore;
183
+ return 0;
184
+ });
185
+ const toClose = closable.slice(0, Math.min(excess, closable.length));
186
+ for (const page of toClose) {
187
+ try {
188
+ await page.close({ runBeforeUnload: false });
189
+ } catch { /* ignore */ }
190
+ }
191
+ } catch { /* ignore enforcement errors */ }
192
+ }
161
193
  addRuntimeEventObserver(observer) {
162
194
  return this.runtimeEvents.addObserver(observer);
163
195
  }
@@ -301,4 +333,4 @@ export class BrowserSession {
301
333
  this.onExit?.(this.options.profileId);
302
334
  }
303
335
  }
304
- //# sourceMappingURL=BrowserSession.js.map
336
+ //# sourceMappingURL=BrowserSession.js.map
@@ -3,14 +3,95 @@ import { ensurePageRuntime } from '../pageRuntime.js';
3
3
  import { resolveNavigationWaitUntil, normalizeUrl, shouldSkipBringToFront } from './utils.js';
4
4
  export class BrowserSessionPageManagement {
5
5
  deps;
6
+ trackedPages = [];
7
+ trackedPageListeners = new WeakSet();
8
+ trackedPageState = new WeakMap();
9
+ static NEW_PAGE_FORCE_ALIVE_MS = 15_000;
10
+ static ACTIVE_PAGE_FORCE_ALIVE_MS = 5_000;
6
11
  constructor(deps) {
7
12
  this.deps = deps;
8
13
  }
14
+ safeIsClosed(page) {
15
+ if (!page)
16
+ return true;
17
+ try {
18
+ return page.isClosed();
19
+ }
20
+ catch {
21
+ return true;
22
+ }
23
+ }
24
+ markTrackedPage(page, forceAliveMs = 0) {
25
+ if (!page)
26
+ return null;
27
+ const prev = this.trackedPageState.get(page);
28
+ const next = {
29
+ closed: false,
30
+ forceAliveUntil: Math.max(Number(prev?.forceAliveUntil || 0), forceAliveMs > 0 ? Date.now() + forceAliveMs : 0),
31
+ };
32
+ this.trackedPageState.set(page, next);
33
+ return page;
34
+ }
35
+ isTrackedPageAlive(page) {
36
+ if (!page)
37
+ return false;
38
+ const state = this.trackedPageState.get(page);
39
+ if (state?.closed === true)
40
+ return false;
41
+ if (!this.safeIsClosed(page))
42
+ return true;
43
+ return Number(state?.forceAliveUntil || 0) > Date.now();
44
+ }
45
+ rememberPage(page, options = {}) {
46
+ if (!page)
47
+ return null;
48
+ this.markTrackedPage(page, Math.max(0, Number(options.forceAliveMs || 0) || 0));
49
+ if (this.safeIsClosed(page) && !this.isTrackedPageAlive(page))
50
+ return null;
51
+ if (!this.trackedPages.includes(page)) {
52
+ this.trackedPages.push(page);
53
+ }
54
+ if (typeof page.on === 'function' && !this.trackedPageListeners.has(page)) {
55
+ page.on('close', () => {
56
+ this.trackedPageState.set(page, {
57
+ closed: true,
58
+ forceAliveUntil: 0,
59
+ });
60
+ this.trackedPages = this.trackedPages.filter((item) => item !== page && !item.isClosed());
61
+ });
62
+ this.trackedPageListeners.add(page);
63
+ }
64
+ return page;
65
+ }
66
+ collectPages(ctx) {
67
+ const active = this.deps.getActivePage();
68
+ if (active) {
69
+ this.rememberPage(active, {
70
+ forceAliveMs: BrowserSessionPageManagement.ACTIVE_PAGE_FORCE_ALIVE_MS,
71
+ });
72
+ }
73
+ const merged = [...ctx.pages(), ...this.trackedPages, ...(active ? [active] : [])];
74
+ const seen = new Set();
75
+ const pages = [];
76
+ for (const page of merged) {
77
+ const tracked = this.trackedPages.includes(page) || page === active;
78
+ const alive = tracked ? this.isTrackedPageAlive(page) : !this.safeIsClosed(page);
79
+ if (!page || !alive || seen.has(page))
80
+ continue;
81
+ seen.add(page);
82
+ pages.push(page);
83
+ this.rememberPage(page);
84
+ }
85
+ this.trackedPages = this.trackedPages.filter((page) => page && this.isTrackedPageAlive(page));
86
+ return pages;
87
+ }
9
88
  async openPageViaContext(ctx, beforeCount) {
10
89
  try {
11
- const page = await ctx.newPage();
90
+ const page = this.rememberPage(await ctx.newPage(), {
91
+ forceAliveMs: BrowserSessionPageManagement.NEW_PAGE_FORCE_ALIVE_MS,
92
+ });
12
93
  await page.waitForLoadState('domcontentloaded', { timeout: 1500 }).catch(() => null);
13
- const after = ctx.pages().filter((p) => !p.isClosed()).length;
94
+ const after = this.collectPages(ctx).length;
14
95
  if (after > beforeCount) {
15
96
  return page;
16
97
  }
@@ -24,8 +105,10 @@ export class BrowserSessionPageManagement {
24
105
  for (let attempt = 1; attempt <= 3; attempt += 1) {
25
106
  const waitPage = ctx.waitForEvent('page', { timeout: 1200 }).catch(() => null);
26
107
  await opener.keyboard.press(shortcut).catch(() => null);
27
- const page = await waitPage;
28
- const pagesNow = ctx.pages().filter((p) => !p.isClosed());
108
+ const page = this.rememberPage(await waitPage, {
109
+ forceAliveMs: BrowserSessionPageManagement.NEW_PAGE_FORCE_ALIVE_MS,
110
+ });
111
+ const pagesNow = this.collectPages(ctx);
29
112
  const after = pagesNow.length;
30
113
  if (page && after > beforeCount)
31
114
  return page;
@@ -54,6 +137,7 @@ export class BrowserSessionPageManagement {
54
137
  const ctx = this.deps.ensureContext();
55
138
  const existing = this.deps.getActivePage();
56
139
  if (existing) {
140
+ this.rememberPage(existing);
57
141
  try {
58
142
  await this.deps.ensurePageViewport(existing);
59
143
  }
@@ -62,7 +146,9 @@ export class BrowserSessionPageManagement {
62
146
  }
63
147
  return existing;
64
148
  }
65
- const page = await ctx.newPage();
149
+ const page = this.rememberPage(await ctx.newPage(), {
150
+ forceAliveMs: BrowserSessionPageManagement.NEW_PAGE_FORCE_ALIVE_MS,
151
+ });
66
152
  this.deps.setActivePage(page);
67
153
  this.deps.setupPageHooks(page);
68
154
  try {
@@ -88,14 +174,7 @@ export class BrowserSessionPageManagement {
88
174
  }
89
175
  listPages() {
90
176
  const ctx = this.deps.ensureContext();
91
- // Filter out closed pages AND pages that are effectively blank (about:newtab/about:blank)
92
- const pages = ctx.pages().filter((p) => {
93
- if (p.isClosed()) return false;
94
- const url = p.url();
95
- // Filter out blank placeholder pages
96
- if (url === 'about:newtab' || url === 'about:blank') return false;
97
- return true;
98
- });
177
+ const pages = this.collectPages(ctx);
99
178
  const active = this.deps.getActivePage();
100
179
  return pages.map((p, index) => ({
101
180
  index,
@@ -103,9 +182,16 @@ export class BrowserSessionPageManagement {
103
182
  active: active === p,
104
183
  }));
105
184
  }
106
- async newPage(url, options = {}) {
107
- const ctx = this.deps.ensureContext();
108
- const isMac = process.platform === 'darwin';
185
+ async newPage(url, options = {}) {
186
+ const ctx = this.deps.ensureContext();
187
+ const maxTabs = typeof this.deps.getMaxTabs === 'function' ? this.deps.getMaxTabs() : null;
188
+ if (Number.isFinite(maxTabs) && maxTabs >= 1) {
189
+ const pages = this.collectPages(ctx);
190
+ if (pages.length >= maxTabs) {
191
+ throw new Error(`max_tabs_exceeded:${maxTabs}`);
192
+ }
193
+ }
194
+ const isMac = process.platform === 'darwin';
109
195
  const shortcut = isMac ? 'Meta+t' : 'Control+t';
110
196
  let page = null;
111
197
  const opener = this.deps.getActivePage() || ctx.pages()[0];
@@ -114,21 +200,23 @@ export class BrowserSessionPageManagement {
114
200
  if (!shouldSkipBringToFront()) {
115
201
  await opener.bringToFront().catch(() => null);
116
202
  }
117
- const before = ctx.pages().filter((p) => !p.isClosed()).length;
203
+ const before = this.collectPages(ctx).length;
118
204
  if (!options?.strictShortcut) {
119
205
  page = await this.openPageViaContext(ctx, before);
120
206
  }
121
207
  if (!page) {
122
208
  page = await this.openPageViaShortcut(ctx, opener, shortcut, before);
123
209
  }
124
- let after = ctx.pages().filter((p) => !p.isClosed()).length;
210
+ let after = this.collectPages(ctx).length;
125
211
  if (!page || after <= before) {
126
212
  const waitPage = ctx.waitForEvent('page', { timeout: 1200 }).catch(() => null);
127
213
  const osShortcutOk = this.tryOsNewTabShortcut();
128
214
  if (osShortcutOk) {
129
- page = await waitPage;
215
+ page = this.rememberPage(await waitPage, {
216
+ forceAliveMs: BrowserSessionPageManagement.NEW_PAGE_FORCE_ALIVE_MS,
217
+ });
130
218
  }
131
- const pagesNow = ctx.pages().filter((p) => !p.isClosed());
219
+ const pagesNow = this.collectPages(ctx);
132
220
  after = pagesNow.length;
133
221
  if (!page && after > before) {
134
222
  page = pagesNow[pagesNow.length - 1] || null;
@@ -137,9 +225,9 @@ export class BrowserSessionPageManagement {
137
225
  if (!page || after <= before) {
138
226
  if (!options?.strictShortcut) {
139
227
  page = await this.openPageViaContext(ctx, before);
140
- after = ctx.pages().filter((p) => !p.isClosed()).length;
228
+ after = this.collectPages(ctx).length;
141
229
  if (!page && after > before) {
142
- const pagesNow = ctx.pages().filter((p) => !p.isClosed());
230
+ const pagesNow = this.collectPages(ctx);
143
231
  page = pagesNow[pagesNow.length - 1] || null;
144
232
  }
145
233
  }
@@ -148,6 +236,9 @@ export class BrowserSessionPageManagement {
148
236
  throw new Error('new_tab_failed');
149
237
  }
150
238
  this.deps.setupPageHooks(page);
239
+ this.rememberPage(page, {
240
+ forceAliveMs: BrowserSessionPageManagement.NEW_PAGE_FORCE_ALIVE_MS,
241
+ });
151
242
  this.deps.setActivePage(page);
152
243
  try {
153
244
  await this.deps.ensurePageViewport(page);
@@ -174,12 +265,12 @@ export class BrowserSessionPageManagement {
174
265
  await ensurePageRuntime(page);
175
266
  this.deps.recordLastKnownUrl(url);
176
267
  }
177
- const pages = ctx.pages().filter((p) => !p.isClosed());
268
+ const pages = this.collectPages(ctx);
178
269
  return { index: Math.max(0, pages.indexOf(page)), url: page.url() };
179
270
  }
180
271
  async switchPage(index) {
181
272
  const ctx = this.deps.ensureContext();
182
- const pages = ctx.pages().filter((p) => !p.isClosed());
273
+ const pages = this.collectPages(ctx);
183
274
  const idx = Number(index);
184
275
  if (!Number.isFinite(idx) || idx < 0 || idx >= pages.length) {
185
276
  throw new Error(`invalid_page_index: ${index}`);
@@ -206,7 +297,7 @@ export class BrowserSessionPageManagement {
206
297
  }
207
298
  async closePage(index) {
208
299
  const ctx = this.deps.ensureContext();
209
- const pages = ctx.pages().filter((p) => !p.isClosed());
300
+ const pages = this.collectPages(ctx);
210
301
  if (pages.length === 0) {
211
302
  return { closedIndex: -1, activeIndex: -1, total: 0 };
212
303
  }
@@ -217,39 +308,10 @@ export class BrowserSessionPageManagement {
217
308
  throw new Error(`invalid_page_index: ${index}`);
218
309
  }
219
310
  const page = pages[closedIndex];
220
- const beforeUrl = page.url();
221
-
222
- // Try to close the page
223
- try {
224
- await page.close({ runBeforeUnload: false });
225
- } catch (e) {
226
- // Ignore close errors
227
- }
228
-
229
- // Wait for close to take effect
230
- await new Promise(r => setTimeout(r, 100));
231
-
232
- // Check if actually closed
233
- let remaining = ctx.pages().filter((p) => !p.isClosed());
234
-
235
- // If still same count, the page might not have closed properly
236
- // Try navigating to about:blank first then close
237
- if (remaining.length === pages.length) {
238
- try {
239
- await page.goto('about:blank', { timeout: 500 }).catch(() => {});
240
- await page.close({ runBeforeUnload: false }).catch(() => {});
241
- await new Promise(r => setTimeout(r, 100));
242
- remaining = ctx.pages().filter((p) => !p.isClosed());
243
- } catch (e) {
244
- // Ignore
245
- }
246
- }
247
-
248
- // Final check - filter out pages that look like closed tabs (about:newtab)
249
- remaining = remaining.filter(p => {
250
- const url = p.url();
251
- return url !== 'about:newtab' && url !== 'about:blank';
252
- });
311
+ this.trackedPageState.set(page, { closed: true, forceAliveUntil: 0 });
312
+ await page.close().catch(() => { });
313
+ this.trackedPages = this.trackedPages.filter((item) => item !== page && !item.isClosed());
314
+ const remaining = this.collectPages(ctx);
253
315
  const nextIndex = remaining.length === 0 ? -1 : Math.min(Math.max(0, closedIndex - 1), remaining.length - 1);
254
316
  if (nextIndex >= 0) {
255
317
  const nextPage = remaining[nextIndex];
@@ -103,3 +103,46 @@ test('newPage falls back to shortcut path in strictShortcut mode', async () => {
103
103
  assert.equal(result.index, 1);
104
104
  assert.equal(getActivePage(), created);
105
105
  });
106
+
107
+ test('listPages keeps track of newly created pages even when context.pages stays stale', async () => {
108
+ const opener = createPage('opener');
109
+ const created = createPage('created');
110
+ const pages = [opener];
111
+ const { management, getActivePage } = createManagement({
112
+ pages,
113
+ activePage: opener,
114
+ ctxNewPage: async () => created,
115
+ waitForEvent: async () => null,
116
+ });
117
+ const result = await management.newPage();
118
+ assert.equal(result.index, 1);
119
+ assert.equal(result.url, 'https://example.com/created');
120
+ assert.equal(getActivePage(), created);
121
+ const listed = management.listPages();
122
+ assert.deepEqual(listed, [
123
+ { index: 0, url: 'https://example.com/opener', active: false },
124
+ { index: 1, url: 'https://example.com/created', active: true },
125
+ ]);
126
+ });
127
+
128
+ test('listPages keeps a just-created page visible briefly even if page reports closed immediately', async () => {
129
+ const opener = createPage('opener');
130
+ const created = createPage('created');
131
+ const pages = [opener];
132
+ const { management } = createManagement({
133
+ pages,
134
+ activePage: opener,
135
+ ctxNewPage: async () => {
136
+ created.closed = true;
137
+ return created;
138
+ },
139
+ waitForEvent: async () => null,
140
+ });
141
+ const result = await management.newPage();
142
+ assert.equal(result.index, 1);
143
+ const listed = management.listPages();
144
+ assert.deepEqual(listed, [
145
+ { index: 0, url: 'https://example.com/opener', active: false },
146
+ { index: 1, url: 'https://example.com/created', active: true },
147
+ ]);
148
+ });
@@ -224,7 +224,7 @@ export class UiController {
224
224
  }
225
225
  const args = ['create', '--profile', payload.profile];
226
226
  if (payload.url) args.push('--url', payload.url);
227
- if (payload.headless !== undefined) args.push('--headless', String(payload.headless));
227
+ if (payload.headless === false) args.push('--no-headless');
228
228
  if (payload.keepOpen !== undefined) args.push('--keep-open', String(payload.keepOpen));
229
229
  return this.runCliCommand('session-manager', args);
230
230
  }
@@ -21,11 +21,18 @@ export function createTransport({ env = process.env, defaults = {}, debugLog = n
21
21
  ? options.timeoutMs
22
22
  : 20000;
23
23
  const profileId = (args?.profileId || args?.profile || args?.sessionId || '').toString();
24
+ const senderMeta = {
25
+ source: String(options?.source || env.CAMO_COMMAND_SOURCE || 'controller').trim() || 'controller',
26
+ cwd: String(options?.cwd || env.CAMO_COMMAND_CWD || process.cwd()).trim() || process.cwd(),
27
+ pid: Number(options?.pid || env.CAMO_COMMAND_PID || process.pid) || process.pid,
28
+ ppid: Number(options?.ppid || env.CAMO_COMMAND_PPID || process.ppid) || process.ppid,
29
+ argv: Array.isArray(options?.argv) ? options.argv.map((item) => String(item)) : process.argv.slice(),
30
+ };
24
31
  debugLog?.('browserServiceCommand:start', { action, profileId, timeoutMs });
25
32
  const res = await fetch(`${getBrowserHttpBase()}/command`, {
26
33
  method: 'POST',
27
34
  headers: { 'Content-Type': 'application/json' },
28
- body: JSON.stringify({ action, args }),
35
+ body: JSON.stringify({ action, args, meta: { sender: senderMeta } }),
29
36
  signal: AbortSignal.timeout ? AbortSignal.timeout(timeoutMs) : undefined,
30
37
  });
31
38
 
@@ -10,6 +10,7 @@ export function ensureUrlScheme(rawUrl) {
10
10
  if (typeof rawUrl !== 'string') return rawUrl;
11
11
  const trimmed = rawUrl.trim();
12
12
  if (!trimmed) return trimmed;
13
+ if (/^(about|chrome|file|data|blob|javascript):/i.test(trimmed)) return trimmed;
13
14
  if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)) return trimmed;
14
15
  return `https://${trimmed}`;
15
16
  }
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
8
8
  import { BROWSER_SERVICE_URL, loadConfig, setRepoRoot } from './config.mjs';
9
9
  import { touchSessionActivity } from '../lifecycle/session-registry.mjs';
10
10
  import { buildResolvedSessionView, resolveSessionViewByProfile } from '../lifecycle/session-view.mjs';
11
+ import { buildCommandSenderMeta } from './command-log.mjs';
11
12
 
12
13
  const require = createRequire(import.meta.url);
13
14
  const DEFAULT_API_TIMEOUT_MS = 90000;
@@ -117,12 +118,23 @@ function shouldTrackSessionActivity(action, payload) {
117
118
 
118
119
  export async function callAPI(action, payload = {}, options = {}) {
119
120
  const timeoutMs = resolveApiTimeoutMs(options);
121
+ const senderMeta = buildCommandSenderMeta({
122
+ source: String(options?.source || payload?.__commandSource || 'browser-service-client').trim() || 'browser-service-client',
123
+ cwd: String(options?.cwd || payload?.__commandCwd || process.cwd()).trim() || process.cwd(),
124
+ pid: Number(options?.pid || payload?.__commandPid || process.pid) || process.pid,
125
+ ppid: Number(options?.ppid || payload?.__commandPpid || process.ppid) || process.ppid,
126
+ argv: Array.isArray(options?.argv)
127
+ ? options.argv
128
+ : Array.isArray(payload?.__commandArgv)
129
+ ? payload.__commandArgv
130
+ : process.argv.slice(),
131
+ });
120
132
  let r;
121
133
  try {
122
134
  r = await fetch(`${BROWSER_SERVICE_URL}/command`, {
123
135
  method: 'POST',
124
136
  headers: { 'Content-Type': 'application/json' },
125
- body: JSON.stringify({ action, args: payload }),
137
+ body: JSON.stringify({ action, args: payload, meta: { sender: senderMeta } }),
126
138
  signal: AbortSignal.timeout(timeoutMs),
127
139
  });
128
140
  } catch (error) {
@@ -0,0 +1,64 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { CONFIG_DIR, ensureDir } from './config.mjs';
4
+
5
+ export const COMMAND_LOG_DIR = path.join(CONFIG_DIR, 'logs');
6
+ export const COMMAND_LOG_FILE = path.join(COMMAND_LOG_DIR, 'command-log.jsonl');
7
+
8
+ function safeSerialize(value) {
9
+ if (value === undefined) return undefined;
10
+ if (value === null) return null;
11
+ try {
12
+ return JSON.parse(JSON.stringify(value));
13
+ } catch {
14
+ return { text: String(value) };
15
+ }
16
+ }
17
+
18
+ function normalizeMeta(meta = {}) {
19
+ const sender = meta?.sender && typeof meta.sender === 'object' ? meta.sender : {};
20
+ return {
21
+ source: String(meta?.source || '').trim() || 'unknown',
22
+ cwd: String(meta?.cwd || sender?.cwd || '').trim() || process.cwd(),
23
+ pid: Number(meta?.pid || sender?.pid || process.pid) || process.pid,
24
+ ppid: Number(meta?.ppid || sender?.ppid || process.ppid) || process.ppid,
25
+ argv: Array.isArray(meta?.argv) ? meta.argv.map((item) => String(item)) : undefined,
26
+ sender: {
27
+ source: String(sender?.source || meta?.source || '').trim() || 'unknown',
28
+ cwd: String(sender?.cwd || meta?.cwd || '').trim() || process.cwd(),
29
+ pid: Number(sender?.pid || meta?.pid || process.pid) || process.pid,
30
+ ppid: Number(sender?.ppid || meta?.ppid || process.ppid) || process.ppid,
31
+ argv: Array.isArray(sender?.argv) ? sender.argv.map((item) => String(item)) : undefined,
32
+ },
33
+ };
34
+ }
35
+
36
+ export function appendCommandLog(entry = {}) {
37
+ try {
38
+ ensureDir(COMMAND_LOG_DIR);
39
+ const meta = normalizeMeta(entry?.meta || {});
40
+ const line = {
41
+ ts: new Date().toISOString(),
42
+ action: String(entry?.action || '').trim() || null,
43
+ profileId: String(entry?.profileId || '').trim() || null,
44
+ command: String(entry?.command || '').trim() || null,
45
+ args: Array.isArray(entry?.args) ? entry.args.map((item) => String(item)) : undefined,
46
+ payload: safeSerialize(entry?.payload),
47
+ meta,
48
+ };
49
+ fs.appendFileSync(COMMAND_LOG_FILE, `${JSON.stringify(line)}\n`, 'utf8');
50
+ return line;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ export function buildCommandSenderMeta(overrides = {}) {
57
+ return {
58
+ source: String(overrides?.source || '').trim() || 'unknown',
59
+ cwd: String(overrides?.cwd || '').trim() || process.cwd(),
60
+ pid: Number(overrides?.pid || process.pid) || process.pid,
61
+ ppid: Number(overrides?.ppid || process.ppid) || process.ppid,
62
+ argv: Array.isArray(overrides?.argv) ? overrides.argv.map((item) => String(item)) : process.argv.slice(),
63
+ };
64
+ }
@@ -26,7 +26,7 @@ CONFIG:
26
26
 
27
27
  BROWSER CONTROL:
28
28
  init Ensure camoufox + ensure browser-service daemon
29
- start [profileId] [--url <url>] [--headless] [--devtools] [--record] [--record-name <name>] [--record-output <path>] [--record-overlay|--no-record-overlay] [--alias <name>] [--idle-timeout <duration>] [--width <w> --height <h>]
29
+ 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>]
30
30
  stop [profileId]
31
31
  stop --id <instanceId> Stop by instance id
32
32
  stop --alias <alias> Stop by alias
@@ -108,8 +108,8 @@ EXAMPLES:
108
108
  camo profile create myprofile
109
109
  camo profile default myprofile
110
110
  camo start --url https://example.com --alias main
111
- camo start worker-1 --headless --alias shard1 --idle-timeout 45m
112
- camo start worker-1 --devtools
111
+ camo start worker-1 --alias shard1 --idle-timeout 45m
112
+ camo start worker-1 --visible --devtools
113
113
  camo start worker-1 --record --record-name xhs-debug --record-output ./logs/xhs-debug.jsonl --record-overlay
114
114
  camo start myprofile --width 1920 --height 1020
115
115
  camo highlight-mode on