@web-auto/camo 0.1.23 → 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.
Files changed (27) hide show
  1. package/package.json +1 -1
  2. package/src/cli.mjs +9 -0
  3. package/src/commands/browser.mjs +9 -7
  4. package/src/container/change-notifier.mjs +90 -39
  5. package/src/container/runtime-core/operations/index.mjs +108 -48
  6. package/src/container/runtime-core/operations/tab-pool.mjs +301 -99
  7. package/src/container/runtime-core/operations/tab-pool.mjs.bak +762 -0
  8. package/src/container/runtime-core/operations/tab-pool.mjs.syntax-error +762 -0
  9. package/src/container/runtime-core/operations/viewport.mjs +46 -0
  10. package/src/container/runtime-core/subscription.mjs +72 -7
  11. package/src/container/runtime-core/validation.mjs +61 -4
  12. package/src/container/subscription-registry.mjs +1 -1
  13. package/src/core/utils.mjs +4 -0
  14. package/src/services/browser-service/index.js +27 -10
  15. package/src/services/browser-service/index.js.bak +671 -0
  16. package/src/services/browser-service/internal/BrowserSession.input.test.js +33 -0
  17. package/src/services/browser-service/internal/BrowserSession.js +34 -2
  18. package/src/services/browser-service/internal/browser-session/input-ops.js +27 -1
  19. package/src/services/browser-service/internal/browser-session/page-management.js +152 -36
  20. package/src/services/browser-service/internal/browser-session/page-management.test.js +148 -0
  21. package/src/services/controller/controller.js +1 -1
  22. package/src/services/controller/transport.js +8 -1
  23. package/src/utils/args.mjs +1 -0
  24. package/src/utils/browser-service.mjs +13 -1
  25. package/src/utils/command-log.mjs +64 -0
  26. package/src/utils/config.mjs +1 -1
  27. package/src/utils/help.mjs +3 -3
@@ -277,6 +277,39 @@ test('mouseWheel retries with refreshed active page after timeout', async () =>
277
277
  restoreTimeout();
278
278
  }
279
279
  });
280
+ test('mouseWheel prefers interactive viewport metrics for anchor clamping', async () => {
281
+ const restoreTimeout = setEnv('CAMO_INPUT_ACTION_TIMEOUT_MS', '80');
282
+ const restoreAttempts = setEnv('CAMO_INPUT_ACTION_MAX_ATTEMPTS', '1');
283
+ const restoreDelay = setEnv('CAMO_INPUT_RECOVERY_DELAY_MS', '0');
284
+ const restoreBringToFrontTimeout = setEnv('CAMO_INPUT_RECOVERY_BRING_TO_FRONT_TIMEOUT_MS', '50');
285
+ const restoreReadySettle = setEnv('CAMO_INPUT_READY_SETTLE_MS', '0');
286
+ try {
287
+ const moves = [];
288
+ const page = {
289
+ isClosed: () => false,
290
+ viewportSize: () => ({ width: 1280, height: 720 }),
291
+ evaluate: async () => ({ innerWidth: 2560, innerHeight: 1440, visualWidth: 2560, visualHeight: 1440 }),
292
+ bringToFront: async () => { },
293
+ waitForTimeout: async () => { },
294
+ mouse: {
295
+ move: async (x, y) => {
296
+ moves.push([x, y]);
297
+ },
298
+ wheel: async () => { },
299
+ },
300
+ };
301
+ const session = createSessionWithPage(page);
302
+ await session.mouseWheel({ deltaY: 360, anchorX: 2564, anchorY: 228 });
303
+ assert.deepEqual(moves, [[2559, 228]]);
304
+ }
305
+ finally {
306
+ restoreReadySettle();
307
+ restoreBringToFrontTimeout();
308
+ restoreDelay();
309
+ restoreAttempts();
310
+ restoreTimeout();
311
+ }
312
+ });
280
313
  test('mouseWheel falls back to keyboard paging when wheel keeps timing out', async () => {
281
314
  const restoreTimeout = setEnv('CAMO_INPUT_ACTION_TIMEOUT_MS', '80');
282
315
  const restoreAttempts = setEnv('CAMO_INPUT_ACTION_MAX_ATTEMPTS', '1');
@@ -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
@@ -1,4 +1,30 @@
1
1
  import { isTimeoutLikeError } from './utils.js';
2
+
3
+ async function readInteractiveViewport(page) {
4
+ const fallback = page.viewportSize?.() || null;
5
+ try {
6
+ const metrics = await page.evaluate(() => ({
7
+ innerWidth: Number(window.innerWidth || 0),
8
+ innerHeight: Number(window.innerHeight || 0),
9
+ visualWidth: Number(window.visualViewport?.width || 0),
10
+ visualHeight: Number(window.visualViewport?.height || 0),
11
+ }));
12
+ const width = Math.max(Number(metrics?.innerWidth || 0), Number(metrics?.visualWidth || 0), Number(fallback?.width || 0));
13
+ const height = Math.max(Number(metrics?.innerHeight || 0), Number(metrics?.visualHeight || 0), Number(fallback?.height || 0));
14
+ if (Number.isFinite(width) && width > 1 && Number.isFinite(height) && height > 1) {
15
+ return {
16
+ width: Math.round(width),
17
+ height: Math.round(height),
18
+ };
19
+ }
20
+ }
21
+ catch { }
22
+ return {
23
+ width: Math.max(1, Number(fallback?.width || 1280)),
24
+ height: Math.max(1, Number(fallback?.height || 720)),
25
+ };
26
+ }
27
+
2
28
  export class BrowserSessionInputOps {
3
29
  ensurePrimaryPage;
4
30
  ensureInputReady;
@@ -89,7 +115,7 @@ export class BrowserSessionInputOps {
89
115
  }
90
116
  try {
91
117
  await this.runInputAction(page, 'mouse:wheel', async (activePage) => {
92
- const viewport = activePage.viewportSize();
118
+ const viewport = await readInteractiveViewport(activePage);
93
119
  const moveX = Number.isFinite(normalizedAnchorX)
94
120
  ? Math.max(1, Math.min(Math.max(1, Number(viewport?.width || 1280) - 1), Math.round(normalizedAnchorX)))
95
121
  : Math.max(1, Math.floor(((viewport?.width || 1280) * 0.5)));
@@ -3,9 +3,122 @@ 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
+ }
88
+ async openPageViaContext(ctx, beforeCount) {
89
+ try {
90
+ const page = this.rememberPage(await ctx.newPage(), {
91
+ forceAliveMs: BrowserSessionPageManagement.NEW_PAGE_FORCE_ALIVE_MS,
92
+ });
93
+ await page.waitForLoadState('domcontentloaded', { timeout: 1500 }).catch(() => null);
94
+ const after = this.collectPages(ctx).length;
95
+ if (after > beforeCount) {
96
+ return page;
97
+ }
98
+ }
99
+ catch {
100
+ // Fall through to shortcut-based creation below.
101
+ }
102
+ return null;
103
+ }
104
+ async openPageViaShortcut(ctx, opener, shortcut, beforeCount) {
105
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
106
+ const waitPage = ctx.waitForEvent('page', { timeout: 1200 }).catch(() => null);
107
+ await opener.keyboard.press(shortcut).catch(() => null);
108
+ const page = this.rememberPage(await waitPage, {
109
+ forceAliveMs: BrowserSessionPageManagement.NEW_PAGE_FORCE_ALIVE_MS,
110
+ });
111
+ const pagesNow = this.collectPages(ctx);
112
+ const after = pagesNow.length;
113
+ if (page && after > beforeCount)
114
+ return page;
115
+ if (!page && after > beforeCount) {
116
+ return pagesNow[pagesNow.length - 1] || null;
117
+ }
118
+ await new Promise((r) => setTimeout(r, 250));
119
+ }
120
+ return null;
121
+ }
9
122
  tryOsNewTabShortcut() {
10
123
  if (this.deps.isHeadless())
11
124
  return false;
@@ -24,6 +137,7 @@ export class BrowserSessionPageManagement {
24
137
  const ctx = this.deps.ensureContext();
25
138
  const existing = this.deps.getActivePage();
26
139
  if (existing) {
140
+ this.rememberPage(existing);
27
141
  try {
28
142
  await this.deps.ensurePageViewport(existing);
29
143
  }
@@ -32,7 +146,9 @@ export class BrowserSessionPageManagement {
32
146
  }
33
147
  return existing;
34
148
  }
35
- const page = await ctx.newPage();
149
+ const page = this.rememberPage(await ctx.newPage(), {
150
+ forceAliveMs: BrowserSessionPageManagement.NEW_PAGE_FORCE_ALIVE_MS,
151
+ });
36
152
  this.deps.setActivePage(page);
37
153
  this.deps.setupPageHooks(page);
38
154
  try {
@@ -58,7 +174,7 @@ export class BrowserSessionPageManagement {
58
174
  }
59
175
  listPages() {
60
176
  const ctx = this.deps.ensureContext();
61
- const pages = ctx.pages().filter((p) => !p.isClosed());
177
+ const pages = this.collectPages(ctx);
62
178
  const active = this.deps.getActivePage();
63
179
  return pages.map((p, index) => ({
64
180
  index,
@@ -66,9 +182,16 @@ export class BrowserSessionPageManagement {
66
182
  active: active === p,
67
183
  }));
68
184
  }
69
- async newPage(url, options = {}) {
70
- const ctx = this.deps.ensureContext();
71
- 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';
72
195
  const shortcut = isMac ? 'Meta+t' : 'Control+t';
73
196
  let page = null;
74
197
  const opener = this.deps.getActivePage() || ctx.pages()[0];
@@ -77,29 +200,23 @@ export class BrowserSessionPageManagement {
77
200
  if (!shouldSkipBringToFront()) {
78
201
  await opener.bringToFront().catch(() => null);
79
202
  }
80
- const before = ctx.pages().filter((p) => !p.isClosed()).length;
81
- for (let attempt = 1; attempt <= 3; attempt += 1) {
82
- const waitPage = ctx.waitForEvent('page', { timeout: 8000 }).catch(() => null);
83
- await opener.keyboard.press(shortcut).catch(() => null);
84
- page = await waitPage;
85
- const pagesNow = ctx.pages().filter((p) => !p.isClosed());
86
- const after = pagesNow.length;
87
- if (page && after > before)
88
- break;
89
- if (!page && after > before) {
90
- page = pagesNow[pagesNow.length - 1] || null;
91
- break;
92
- }
93
- await new Promise((r) => setTimeout(r, 250));
203
+ const before = this.collectPages(ctx).length;
204
+ if (!options?.strictShortcut) {
205
+ page = await this.openPageViaContext(ctx, before);
94
206
  }
95
- let after = ctx.pages().filter((p) => !p.isClosed()).length;
207
+ if (!page) {
208
+ page = await this.openPageViaShortcut(ctx, opener, shortcut, before);
209
+ }
210
+ let after = this.collectPages(ctx).length;
96
211
  if (!page || after <= before) {
97
- const waitPage = ctx.waitForEvent('page', { timeout: 8000 }).catch(() => null);
212
+ const waitPage = ctx.waitForEvent('page', { timeout: 1200 }).catch(() => null);
98
213
  const osShortcutOk = this.tryOsNewTabShortcut();
99
214
  if (osShortcutOk) {
100
- page = await waitPage;
215
+ page = this.rememberPage(await waitPage, {
216
+ forceAliveMs: BrowserSessionPageManagement.NEW_PAGE_FORCE_ALIVE_MS,
217
+ });
101
218
  }
102
- const pagesNow = ctx.pages().filter((p) => !p.isClosed());
219
+ const pagesNow = this.collectPages(ctx);
103
220
  after = pagesNow.length;
104
221
  if (!page && after > before) {
105
222
  page = pagesNow[pagesNow.length - 1] || null;
@@ -107,16 +224,10 @@ export class BrowserSessionPageManagement {
107
224
  }
108
225
  if (!page || after <= before) {
109
226
  if (!options?.strictShortcut) {
110
- try {
111
- page = await ctx.newPage();
112
- await page.waitForLoadState('domcontentloaded', { timeout: 8000 }).catch(() => null);
113
- }
114
- catch {
115
- // ignore fallback errors
116
- }
117
- after = ctx.pages().filter((p) => !p.isClosed()).length;
227
+ page = await this.openPageViaContext(ctx, before);
228
+ after = this.collectPages(ctx).length;
118
229
  if (!page && after > before) {
119
- const pagesNow = ctx.pages().filter((p) => !p.isClosed());
230
+ const pagesNow = this.collectPages(ctx);
120
231
  page = pagesNow[pagesNow.length - 1] || null;
121
232
  }
122
233
  }
@@ -125,6 +236,9 @@ export class BrowserSessionPageManagement {
125
236
  throw new Error('new_tab_failed');
126
237
  }
127
238
  this.deps.setupPageHooks(page);
239
+ this.rememberPage(page, {
240
+ forceAliveMs: BrowserSessionPageManagement.NEW_PAGE_FORCE_ALIVE_MS,
241
+ });
128
242
  this.deps.setActivePage(page);
129
243
  try {
130
244
  await this.deps.ensurePageViewport(page);
@@ -151,12 +265,12 @@ export class BrowserSessionPageManagement {
151
265
  await ensurePageRuntime(page);
152
266
  this.deps.recordLastKnownUrl(url);
153
267
  }
154
- const pages = ctx.pages().filter((p) => !p.isClosed());
268
+ const pages = this.collectPages(ctx);
155
269
  return { index: Math.max(0, pages.indexOf(page)), url: page.url() };
156
270
  }
157
271
  async switchPage(index) {
158
272
  const ctx = this.deps.ensureContext();
159
- const pages = ctx.pages().filter((p) => !p.isClosed());
273
+ const pages = this.collectPages(ctx);
160
274
  const idx = Number(index);
161
275
  if (!Number.isFinite(idx) || idx < 0 || idx >= pages.length) {
162
276
  throw new Error(`invalid_page_index: ${index}`);
@@ -183,7 +297,7 @@ export class BrowserSessionPageManagement {
183
297
  }
184
298
  async closePage(index) {
185
299
  const ctx = this.deps.ensureContext();
186
- const pages = ctx.pages().filter((p) => !p.isClosed());
300
+ const pages = this.collectPages(ctx);
187
301
  if (pages.length === 0) {
188
302
  return { closedIndex: -1, activeIndex: -1, total: 0 };
189
303
  }
@@ -194,8 +308,10 @@ export class BrowserSessionPageManagement {
194
308
  throw new Error(`invalid_page_index: ${index}`);
195
309
  }
196
310
  const page = pages[closedIndex];
311
+ this.trackedPageState.set(page, { closed: true, forceAliveUntil: 0 });
197
312
  await page.close().catch(() => { });
198
- const remaining = ctx.pages().filter((p) => !p.isClosed());
313
+ this.trackedPages = this.trackedPages.filter((item) => item !== page && !item.isClosed());
314
+ const remaining = this.collectPages(ctx);
199
315
  const nextIndex = remaining.length === 0 ? -1 : Math.min(Math.max(0, closedIndex - 1), remaining.length - 1);
200
316
  if (nextIndex >= 0) {
201
317
  const nextPage = remaining[nextIndex];
@@ -0,0 +1,148 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { BrowserSessionPageManagement } from './page-management.js';
4
+
5
+ function createPage(label) {
6
+ const page = {
7
+ label,
8
+ closed: false,
9
+ bringToFrontCalls: 0,
10
+ gotoCalls: [],
11
+ waitCalls: [],
12
+ keyboard: {
13
+ presses: [],
14
+ press: async (shortcut) => {
15
+ page.keyboard.presses.push(shortcut);
16
+ },
17
+ },
18
+ url: () => `https://example.com/${label}`,
19
+ isClosed() {
20
+ return this.closed;
21
+ },
22
+ async bringToFront() {
23
+ this.bringToFrontCalls += 1;
24
+ },
25
+ async waitForLoadState(_state, opts) {
26
+ this.waitCalls.push(Number(opts?.timeout || 0));
27
+ },
28
+ async goto(url) {
29
+ this.gotoCalls.push(url);
30
+ },
31
+ };
32
+ return page;
33
+ }
34
+
35
+ function createManagement({ pages, activePage, ctxNewPage, waitForEvent }) {
36
+ let currentActive = activePage;
37
+ const ctx = {
38
+ pages: () => pages,
39
+ newPage: ctxNewPage,
40
+ waitForEvent: waitForEvent || (async () => null),
41
+ };
42
+ const management = new BrowserSessionPageManagement({
43
+ ensureContext: () => ctx,
44
+ getActivePage: () => currentActive,
45
+ getCurrentUrl: () => currentActive?.url?.() || null,
46
+ setActivePage: (page) => {
47
+ currentActive = page ?? null;
48
+ },
49
+ setupPageHooks: () => { },
50
+ ensurePageViewport: async () => { },
51
+ maybeCenterPage: async () => { },
52
+ recordLastKnownUrl: () => { },
53
+ isHeadless: () => false,
54
+ });
55
+ return { management, getActivePage: () => currentActive };
56
+ }
57
+
58
+ test('newPage prefers direct context creation before shortcut retries', async () => {
59
+ const opener = createPage('opener');
60
+ const created = createPage('created');
61
+ const pages = [opener];
62
+ let ctxNewPageCalls = 0;
63
+ const { management, getActivePage } = createManagement({
64
+ pages,
65
+ activePage: opener,
66
+ ctxNewPage: async () => {
67
+ ctxNewPageCalls += 1;
68
+ pages.push(created);
69
+ return created;
70
+ },
71
+ waitForEvent: async () => {
72
+ throw new Error('shortcut path should not run');
73
+ },
74
+ });
75
+ const result = await management.newPage();
76
+ assert.equal(ctxNewPageCalls, 1);
77
+ assert.equal(opener.keyboard.presses.length, 0);
78
+ assert.equal(result.index, 1);
79
+ assert.equal(result.url, 'https://example.com/created');
80
+ assert.equal(getActivePage(), created);
81
+ });
82
+
83
+ test('newPage falls back to shortcut path in strictShortcut mode', async () => {
84
+ const opener = createPage('opener');
85
+ const created = createPage('created');
86
+ const pages = [opener];
87
+ let ctxNewPageCalls = 0;
88
+ const { management, getActivePage } = createManagement({
89
+ pages,
90
+ activePage: opener,
91
+ ctxNewPage: async () => {
92
+ ctxNewPageCalls += 1;
93
+ return created;
94
+ },
95
+ waitForEvent: async () => {
96
+ pages.push(created);
97
+ return created;
98
+ },
99
+ });
100
+ const result = await management.newPage(undefined, { strictShortcut: true });
101
+ assert.equal(ctxNewPageCalls, 0);
102
+ assert.ok(opener.keyboard.presses.length >= 1);
103
+ assert.equal(result.index, 1);
104
+ assert.equal(getActivePage(), created);
105
+ });
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) {