@wenrwa/marketplace-cli 0.7.1 → 0.7.3

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.
@@ -62,7 +62,10 @@ class FakeBrowserDriver {
62
62
  }
63
63
  async back() { }
64
64
  async forward() { }
65
- async reload() { }
65
+ reloadCalls = 0;
66
+ async reload() {
67
+ this.reloadCalls += 1;
68
+ }
66
69
  onFrame(cb) {
67
70
  this.frameCb = cb;
68
71
  }
@@ -1120,3 +1123,80 @@ describe('relay client — browser no-consumer sweep (cbs-14)', () => {
1120
1123
  await closeRelay(relay.wss);
1121
1124
  });
1122
1125
  });
1126
+ describe('relay client — browser tabs (cvt-07)', () => {
1127
+ async function boot(extra = {}) {
1128
+ const relay = await startFakeRelay();
1129
+ const sessions = new sessionManager_1.SessionManager(makeFactory());
1130
+ const drivers = {};
1131
+ let clock = 1_000;
1132
+ const handle = (0, relayClient_1.startRelayClient)({
1133
+ relayUrl: `ws://127.0.0.1:${relay.port}`,
1134
+ relayToken: 'tok',
1135
+ sessions,
1136
+ nowFn: () => clock,
1137
+ createBrowserDriver: ({ sessionId }) => {
1138
+ const d = new FakeBrowserDriver();
1139
+ drivers[sessionId] = d;
1140
+ return d;
1141
+ },
1142
+ ...extra,
1143
+ });
1144
+ const ws = await relay.nextConnection();
1145
+ const q = frameQueue(ws);
1146
+ const auth = await q.next();
1147
+ const settle = () => new Promise((r) => setTimeout(r, 20));
1148
+ const open = async (sessionId, url) => {
1149
+ ws.send(JSON.stringify({ t: 'browser-open', sessionId, url }));
1150
+ await settle();
1151
+ };
1152
+ return {
1153
+ relay, handle, ws, q, auth, drivers, open, settle,
1154
+ advance: (ms) => { clock += ms; },
1155
+ done: async () => { handle.close(); await closeRelay(relay.wss); },
1156
+ };
1157
+ }
1158
+ it('advertises its capabilities in the auth frame (and nothing when it has none)', async () => {
1159
+ const withCaps = await boot({ capabilities: ['browser-tabs'] });
1160
+ expect(withCaps.auth).toEqual({ v: 1, relayToken: 'tok', caps: ['browser-tabs'] });
1161
+ await withCaps.done();
1162
+ const without = await boot();
1163
+ expect(without.auth).toEqual({ v: 1, relayToken: 'tok' });
1164
+ await without.done();
1165
+ });
1166
+ it('two pane sessions are two independent tabs: reload and close reach only their own', async () => {
1167
+ const t = await boot();
1168
+ await t.open('browser:c1:pane-a', 'https://a.example');
1169
+ await t.open('browser:c1:pane-b', 'https://b.example');
1170
+ const a = t.drivers['browser:c1:pane-a'];
1171
+ const b = t.drivers['browser:c1:pane-b'];
1172
+ expect(a.startCalls).toEqual(['https://a.example']);
1173
+ expect(b.startCalls).toEqual(['https://b.example']);
1174
+ t.ws.send(JSON.stringify({ t: 'browser-reload', sessionId: 'browser:c1:pane-a' }));
1175
+ await t.settle();
1176
+ expect(a.reloadCalls).toBe(1);
1177
+ expect(b.reloadCalls).toBe(0);
1178
+ t.ws.send(JSON.stringify({ t: 'browser-close', sessionId: 'browser:c1:pane-b' }));
1179
+ await t.settle();
1180
+ expect(b.stopCalls).toBe(1);
1181
+ expect(a.stopCalls).toBe(0);
1182
+ await t.done();
1183
+ });
1184
+ it('at the tab cap, opening one more puts the stalest tab to sleep (tab-evicted)', async () => {
1185
+ const t = await boot({ maxBrowserTabs: 2 });
1186
+ await t.open('browser:c1:p1', 'https://one.example');
1187
+ t.advance(1_000);
1188
+ await t.open('browser:c1:p2', 'https://two.example');
1189
+ t.advance(1_000);
1190
+ // p1 is being watched (an ack) — p2 is now the stalest.
1191
+ t.ws.send(JSON.stringify({ t: 'browser-frame-ack', sessionId: 'browser:c1:p1', seq: 1 }));
1192
+ await t.settle();
1193
+ t.advance(1_000);
1194
+ await t.open('browser:c1:p3', 'https://three.example');
1195
+ expect(t.drivers['browser:c1:p2'].sleepCalls).toEqual(['tab-evicted']);
1196
+ expect(t.drivers['browser:c1:p1'].sleepCalls).toEqual([]);
1197
+ expect(t.drivers['browser:c1:p3'].startCalls).toEqual(['https://three.example']);
1198
+ const evicted = await t.q.next();
1199
+ expect(evicted).toEqual({ t: 'browser-state', sessionId: 'browser:c1:p2', state: 'asleep', reason: 'tab-evicted' });
1200
+ await t.done();
1201
+ });
1202
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,206 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * cvt-07 — each cloud Browser pane is its own TAB in the box's one browser.
5
+ *
6
+ * Two drivers sharing one browserHost.ts against a faked multi-target CDP
7
+ * endpoint that behaves like Chromium's flattened sessions: every page target
8
+ * gets its own CDP session id, screencast frames carry that session id, and a
9
+ * navigation/reload repaints ONLY the target it was sent to.
10
+ *
11
+ * docs/prd/canvas-tools.md (module 7) + issues/cvt-07-cloud-browser-tabs.md.
12
+ */
13
+ const browserDriver_1 = require("../bridge/browserDriver");
14
+ const browserHost_1 = require("../bridge/browserHost");
15
+ class FakeChildProcess {
16
+ killCount = 0;
17
+ stderr = { on: () => { } };
18
+ exitHandlers = [];
19
+ on(event, cb) {
20
+ if (event === 'exit')
21
+ this.exitHandlers.push(cb);
22
+ }
23
+ kill() {
24
+ this.killCount += 1;
25
+ }
26
+ emitExit(code, signal = null) {
27
+ for (const cb of this.exitHandlers)
28
+ cb(code, signal);
29
+ }
30
+ }
31
+ /** A fake Chromium DevTools endpoint with real per-target state: which URL
32
+ * each page shows, and which CDP session drives which target. */
33
+ class FakeChromium {
34
+ sent = [];
35
+ pages = new Map([['page-initial', { url: 'about:blank' }]]);
36
+ sessions = new Map(); // cdp session id -> target id
37
+ nextTarget = 1;
38
+ nextSession = 1;
39
+ onMessage = null;
40
+ onCloseCb = null;
41
+ transport = {
42
+ send: (data) => {
43
+ const msg = JSON.parse(data);
44
+ this.sent.push(msg);
45
+ const result = this.handle(msg);
46
+ queueMicrotask(() => this.onMessage?.(JSON.stringify({ id: msg.id, result })));
47
+ },
48
+ close: () => {
49
+ queueMicrotask(() => this.onCloseCb?.(undefined));
50
+ },
51
+ on: (event, cb) => {
52
+ if (event === 'message')
53
+ this.onMessage = cb;
54
+ else if (event === 'close')
55
+ this.onCloseCb = cb;
56
+ },
57
+ };
58
+ handle(msg) {
59
+ const target = msg.sessionId ? this.sessions.get(msg.sessionId) : undefined;
60
+ switch (msg.method) {
61
+ case 'Target.getTargets':
62
+ return { targetInfos: [...this.pages.keys()].map((targetId) => ({ targetId, type: 'page' })) };
63
+ case 'Target.createTarget': {
64
+ const targetId = `page-${this.nextTarget++}`;
65
+ this.pages.set(targetId, { url: msg.params.url });
66
+ return { targetId };
67
+ }
68
+ case 'Target.attachToTarget': {
69
+ const sessionId = `cdp-${this.nextSession++}`;
70
+ this.sessions.set(sessionId, msg.params.targetId);
71
+ return { sessionId };
72
+ }
73
+ case 'Target.closeTarget':
74
+ this.pages.delete(msg.params.targetId);
75
+ return { success: true };
76
+ case 'Page.navigate':
77
+ if (target)
78
+ this.pages.get(target).url = msg.params.url;
79
+ if (msg.sessionId)
80
+ this.repaint(msg.sessionId);
81
+ return { frameId: 'f' };
82
+ case 'Page.reload':
83
+ if (msg.sessionId)
84
+ this.repaint(msg.sessionId);
85
+ return {};
86
+ case 'Page.captureScreenshot':
87
+ return { data: `shot:${target ? this.pages.get(target)?.url : '?'}` };
88
+ default:
89
+ return {};
90
+ }
91
+ }
92
+ /** Chromium repaints the target a command was sent to — and only it. */
93
+ repaint(cdpSessionId) {
94
+ const target = this.sessions.get(cdpSessionId);
95
+ const url = this.pages.get(target)?.url;
96
+ queueMicrotask(() => this.onMessage?.(JSON.stringify({
97
+ method: 'Page.screencastFrame',
98
+ sessionId: cdpSessionId,
99
+ params: { sessionId: 1, data: `paint:${url}`, metadata: { deviceWidth: 1280, deviceHeight: 800 } },
100
+ })));
101
+ }
102
+ calls(method) {
103
+ return this.sent.filter((m) => m.method === method);
104
+ }
105
+ }
106
+ const flush = () => new Promise((resolve) => setImmediate(resolve));
107
+ function setup() {
108
+ const chromium = new FakeChromium();
109
+ const proc = new FakeChildProcess();
110
+ let launches = 0;
111
+ const launchChromiumFn = () => {
112
+ launches += 1;
113
+ return { proc, wsUrl: Promise.resolve('ws://fake-devtools') };
114
+ };
115
+ const host = (0, browserHost_1.createBrowserHost)({ launchChromiumFn, wsFactory: () => chromium.transport });
116
+ const noTimer = { setIntervalFn: () => 'h', clearIntervalFn: () => { } };
117
+ const tab = () => {
118
+ const driver = (0, browserDriver_1.createBrowserDriver)({ host, ...noTimer });
119
+ const frames = [];
120
+ driver.onFrame((f) => {
121
+ frames.push(f);
122
+ driver.ackFrame();
123
+ });
124
+ return { driver, frames };
125
+ };
126
+ return { chromium, proc, host, tab, launches: () => launches };
127
+ }
128
+ describe('browser tabs (cvt-07)', () => {
129
+ it('two panes share ONE Chromium and each gets its own page target', async () => {
130
+ const { chromium, host, tab, launches } = setup();
131
+ const a = tab();
132
+ const b = tab();
133
+ await Promise.all([a.driver.start('https://a.example'), b.driver.start('https://b.example')]);
134
+ expect(launches()).toBe(1);
135
+ expect(host.leaseCount).toBe(2);
136
+ // The first tab claims Chromium's launch page; the second creates its own.
137
+ expect(chromium.calls('Target.createTarget')).toHaveLength(1);
138
+ expect([...chromium.pages.values()].map((p) => p.url).sort()).toEqual(['https://a.example', 'https://b.example']);
139
+ });
140
+ it('navigates independently: each pane only ever sees its own site', async () => {
141
+ const { tab } = setup();
142
+ const a = tab();
143
+ const b = tab();
144
+ await a.driver.start('https://a.example');
145
+ await b.driver.start('https://b.example');
146
+ await flush();
147
+ await a.driver.navigate('https://a.example/next');
148
+ await flush();
149
+ expect(a.frames.map((f) => f.data).every((d) => d.includes('a.example'))).toBe(true);
150
+ expect(b.frames.map((f) => f.data).every((d) => d.includes('b.example'))).toBe(true);
151
+ expect(a.frames.some((f) => f.data.includes('a.example/next'))).toBe(true);
152
+ });
153
+ it('reloading one pane emits no frames for the other', async () => {
154
+ const { chromium, tab } = setup();
155
+ const a = tab();
156
+ const b = tab();
157
+ await a.driver.start('https://a.example');
158
+ await b.driver.start('https://b.example');
159
+ await flush();
160
+ const aBefore = a.frames.length;
161
+ const bBefore = b.frames.length;
162
+ await a.driver.reload();
163
+ await flush();
164
+ expect(a.frames.length).toBeGreaterThan(aBefore);
165
+ expect(b.frames.length).toBe(bBefore);
166
+ // The reload went to A's CDP session only.
167
+ const reloads = chromium.calls('Page.reload');
168
+ expect(reloads).toHaveLength(1);
169
+ expect(chromium.sessions.get(reloads[0].sessionId)).not.toBe(chromium.sessions.get(chromium.calls('Page.navigate').find((n) => n.params.url === 'https://b.example').sessionId));
170
+ });
171
+ it('closing a pane closes only its tab; the last tab closed kills Chromium', async () => {
172
+ const { chromium, proc, host, tab } = setup();
173
+ const a = tab();
174
+ const b = tab();
175
+ const bStates = [];
176
+ b.driver.onState((s) => bStates.push(s.state));
177
+ await a.driver.start('https://a.example');
178
+ await b.driver.start('https://b.example');
179
+ a.driver.sleep('closed-by-pane');
180
+ await flush();
181
+ expect(proc.killCount).toBe(0);
182
+ expect(host.leaseCount).toBe(1);
183
+ expect([...chromium.pages.values()].map((p) => p.url)).toEqual(['https://b.example']);
184
+ expect(bStates).not.toContain('asleep');
185
+ expect(bStates).not.toContain('closed');
186
+ b.driver.stop();
187
+ expect(proc.killCount).toBe(1);
188
+ expect(host.leaseCount).toBe(0);
189
+ });
190
+ it('a Chromium crash closes every tab, and the next pane relaunches it', async () => {
191
+ const { proc, tab, launches } = setup();
192
+ const a = tab();
193
+ const b = tab();
194
+ const states = [];
195
+ a.driver.onState((s) => states.push(`a:${s.state}`));
196
+ b.driver.onState((s) => states.push(`b:${s.state}`));
197
+ await a.driver.start('https://a.example');
198
+ await b.driver.start('https://b.example');
199
+ proc.emitExit(1);
200
+ expect(states).toContain('a:closed');
201
+ expect(states).toContain('b:closed');
202
+ const c = tab();
203
+ await c.driver.start('https://c.example');
204
+ expect(launches()).toBe(2);
205
+ });
206
+ });
@@ -80,3 +80,17 @@ describe('hasClaudeSessionHistory', () => {
80
80
  expect(result).toBe(true);
81
81
  });
82
82
  });
83
+ describe('a registered folder with a trailing slash (2026-10-04 "Session ID … is already in use")', () => {
84
+ it('slugs exactly like the same folder without the slash', () => {
85
+ expect((0, claudeSessionHistory_1.claudeProjectSlug)('/Users/me/Wenrwa Projects/Site/')).toBe((0, claudeSessionHistory_1.claudeProjectSlug)('/Users/me/Wenrwa Projects/Site'));
86
+ expect((0, claudeSessionHistory_1.claudeProjectSlug)('/Users/me/Wenrwa Projects/Site/')).toBe('-Users-me-Wenrwa-Projects-Site');
87
+ });
88
+ it('finds the agent transcript, so the launch resumes instead of re-creating the session', () => {
89
+ const seen = [];
90
+ const found = (0, claudeSessionHistory_1.hasAgentSessionHistory)('/Users/me/Wenrwa Projects/Site/', 'abc', {
91
+ env: { CLAUDE_CONFIG_DIR: '/cfg' },
92
+ existsSync: (p) => { seen.push(String(p)); return String(p) === '/cfg/projects/-Users-me-Wenrwa-Projects-Site/abc.jsonl'; },
93
+ });
94
+ expect(found).toBe(true);
95
+ });
96
+ });
@@ -37,6 +37,7 @@
37
37
  */
38
38
  import { ChromiumLaunchHandle } from './chromiumLauncher';
39
39
  import { CdpTransportLike } from './cdpClient';
40
+ import type { BrowserHost } from './browserHost';
40
41
  import { BrowserInputEvent, BrowserSessionState } from './browserFrames';
41
42
  /** container -> pane screencast frame, minus the wire envelope (`t`,
42
43
  * `sessionId`) — the driver doesn't know the relay session id; the caller
@@ -228,6 +229,13 @@ export interface BrowserDriverOpts {
228
229
  * log line (frames delivered, screencast vs capture split, drops) every
229
230
  * N frames processed, never per-frame. Default 50. */
230
231
  summaryFrameInterval?: number;
232
+ /** cvt-07: the box's shared Chromium (browserHost.ts). When set, this
233
+ * driver is ONE TAB in that process — it leases the browser connection,
234
+ * creates (or claims the spare) page target, and on stop()/sleep() closes
235
+ * only its own target; the host kills Chromium when the last tab goes.
236
+ * Absent → the pre-cvt-07 behaviour: this driver launches and owns its
237
+ * own Chromium (launchChromiumFn/wsFactory above). */
238
+ host?: BrowserHost;
231
239
  }
232
240
  /** Default real transport: wraps a `ws` WebSocket connected to the
233
241
  * Chromium DevTools endpoint. Never exercised by tests (they inject
@@ -137,6 +137,9 @@ function createBrowserDriver(opts = {}) {
137
137
  const summaryFrameInterval = opts.summaryFrameInterval ?? 50;
138
138
  let cdp = null;
139
139
  let childProc = null;
140
+ // cvt-07: set only in shared-host (tab) mode — see BrowserDriverOpts.host.
141
+ let hostLease = null;
142
+ const unsubscribers = [];
140
143
  let pageTargetId = null;
141
144
  let targetSessionId = null;
142
145
  let seq = 0;
@@ -238,6 +241,19 @@ function createBrowserDriver(opts = {}) {
238
241
  if (stopped)
239
242
  return;
240
243
  stopped = true;
244
+ // cvt-07: a tab never closes the shared connection or kills the shared
245
+ // process — it closes its own target and hands its lease back (the host
246
+ // kills Chromium when the last lease goes).
247
+ if (opts.host) {
248
+ for (const off of unsubscribers.splice(0))
249
+ off();
250
+ const lease = hostLease;
251
+ if (lease && pageTargetId) {
252
+ void lease.cdp.send('Target.closeTarget', { targetId: pageTargetId }).catch(() => { });
253
+ }
254
+ lease?.release();
255
+ return;
256
+ }
241
257
  try {
242
258
  cdp?.close();
243
259
  }
@@ -299,28 +315,55 @@ function createBrowserDriver(opts = {}) {
299
315
  }
300
316
  async function start(url) {
301
317
  emitState({ state: 'opening' });
302
- const { proc, wsUrl } = launchChromiumFn({ chromiumPath, userDataDir, width, height });
303
- childProc = proc;
304
- proc.on('exit', (code, signal) => {
305
- handleClosed(`chromium-exit code=${code ?? 'null'} signal=${signal ?? 'null'}`);
306
- });
307
- proc.on('error', (err) => {
308
- handleClosed(`chromium-error ${err instanceof Error ? err.message : String(err)}`);
309
- });
310
- const browserWsUrl = await wsUrl;
311
- const transport = wsFactory(browserWsUrl);
312
- const client = (0, cdpClient_1.createCdpClient)(transport);
313
- cdp = client;
314
- client.onClose((info) => {
315
- handleClosed(info?.reason ? `cdp-closed:${info.reason}` : 'cdp-closed');
316
- });
317
- client.onEvent('Target.targetInfoChanged', (params) => {
318
+ let client;
319
+ if (opts.host) {
320
+ // cvt-07: one tab in the box's shared Chromium.
321
+ const lease = await opts.host.acquire();
322
+ if (stopped) {
323
+ // stop()/sleep() landed while the lease was being acquired.
324
+ lease.release();
325
+ return;
326
+ }
327
+ hostLease = lease;
328
+ client = lease.cdp;
329
+ cdp = client;
330
+ lease.onClosed((reason) => handleClosed(reason));
331
+ }
332
+ else {
333
+ const { proc, wsUrl } = launchChromiumFn({ chromiumPath, userDataDir, width, height });
334
+ childProc = proc;
335
+ proc.on('exit', (code, signal) => {
336
+ handleClosed(`chromium-exit code=${code ?? 'null'} signal=${signal ?? 'null'}`);
337
+ });
338
+ proc.on('error', (err) => {
339
+ handleClosed(`chromium-error ${err instanceof Error ? err.message : String(err)}`);
340
+ });
341
+ const browserWsUrl = await wsUrl;
342
+ const transport = wsFactory(browserWsUrl);
343
+ client = (0, cdpClient_1.createCdpClient)(transport);
344
+ cdp = client;
345
+ client.onClose((info) => {
346
+ handleClosed(info?.reason ? `cdp-closed:${info.reason}` : 'cdp-closed');
347
+ });
348
+ }
349
+ unsubscribers.push(client.onEvent('Target.targetInfoChanged', (params) => {
318
350
  const info = params.targetInfo;
319
351
  if (!info || !pageTargetId || info.targetId !== pageTargetId)
320
352
  return;
321
353
  emitState({ state: 'open', url: info.url, title: info.title });
322
- });
323
- client.onEvent('Page.screencastFrame', (params) => {
354
+ }));
355
+ // cvt-07: the tab itself went away (crash, window.close()) — only THIS
356
+ // tab is terminal; its siblings in the shared browser are untouched.
357
+ unsubscribers.push(client.onEvent('Target.detachedFromTarget', (params) => {
358
+ if (targetSessionId && params.sessionId === targetSessionId)
359
+ handleClosed('target-detached');
360
+ }));
361
+ unsubscribers.push(client.onEvent('Page.screencastFrame', (params, cdpSessionId) => {
362
+ // cvt-07: on a shared connection every tab's screencast arrives here —
363
+ // only this tab's frames are ours. (A frame without a session id can
364
+ // only come from a non-flattened, single-tab connection.)
365
+ if (cdpSessionId && cdpSessionId !== targetSessionId)
366
+ return;
324
367
  // cbs-14: defensive — Page.stopScreencast (sent by pause()) should
325
368
  // already suppress these at the source, but a frame already in
326
369
  // flight when we asked Chromium to stop is possible; drop it rather
@@ -346,21 +389,35 @@ function createBrowserDriver(opts = {}) {
346
389
  w: metadata.deviceWidth ?? width,
347
390
  h: metadata.deviceHeight ?? height,
348
391
  });
349
- });
350
- const { targetInfos } = await client.send('Target.getTargets');
351
- const existingPage = targetInfos.find((t) => t.type === 'page');
352
- if (existingPage) {
353
- pageTargetId = existingPage.targetId;
392
+ }));
393
+ if (hostLease) {
394
+ // cvt-07: the first tab claims the page Chromium launched with; every
395
+ // other tab gets a target of its own.
396
+ const spare = await hostLease.takeSparePage();
397
+ pageTargetId = spare ?? (await client.send('Target.createTarget', { url: 'about:blank' })).targetId;
354
398
  }
355
399
  else {
356
- const created = await client.send('Target.createTarget', { url: 'about:blank' });
357
- pageTargetId = created.targetId;
400
+ const { targetInfos } = await client.send('Target.getTargets');
401
+ const existingPage = targetInfos.find((t) => t.type === 'page');
402
+ if (existingPage) {
403
+ pageTargetId = existingPage.targetId;
404
+ }
405
+ else {
406
+ const created = await client.send('Target.createTarget', { url: 'about:blank' });
407
+ pageTargetId = created.targetId;
408
+ }
358
409
  }
359
410
  const attached = await client.send('Target.attachToTarget', {
360
411
  targetId: pageTargetId,
361
412
  flatten: true,
362
413
  });
363
414
  targetSessionId = attached.sessionId;
415
+ if (stopped && hostLease) {
416
+ // cvt-07: closed while the tab was still being created — teardown()
417
+ // ran before the target existed, so close it here or it leaks.
418
+ void client.send('Target.closeTarget', { targetId: pageTargetId }).catch(() => { });
419
+ return;
420
+ }
364
421
  await client.send('Page.enable', {}, targetSessionId);
365
422
  // The launcher's --hide-scrollbars only governs the REAL window; the
366
423
  // moment we emulate a viewport (setDeviceMetricsOverride, below/resize)
@@ -0,0 +1,54 @@
1
+ /**
2
+ * cvt-07 — the box's ONE headless Chromium, shared by every Browser pane.
3
+ *
4
+ * (docs/prd/canvas-tools.md module 7, issues/cvt-07-cloud-browser-tabs.md)
5
+ * Before cvt-07 each browser driver launched its own Chromium, and the arena
6
+ * only ever opened one session per box (`browser:<containerId>`), so two
7
+ * cloud Browser panes fought over one page. Now every Browser pane is its own
8
+ * relay session (`browser:<containerId>:<paneId>`) and its own TAB (CDP page
9
+ * target) in this one process: same box, same cost, one profile.
10
+ *
11
+ * This module owns only the PROCESS + the browser-level CDP connection,
12
+ * reference-counted by lease:
13
+ * - the first acquire() launches Chromium and connects; concurrent
14
+ * acquires during the launch share it;
15
+ * - each lease may claim the spare `about:blank` page Chromium starts with
16
+ * (so the first tab doesn't leave an idle extra renderer behind);
17
+ * - the last release() kills Chromium (exactly what a single driver's
18
+ * stop()/sleep() did before — no orphaned process, no idle memory);
19
+ * - a Chromium exit / CDP close tells EVERY live lease (each tab goes to
20
+ * its terminal state), and the next acquire() relaunches.
21
+ * Targets, screencasts and input stay in browserDriver.ts — one driver per tab.
22
+ */
23
+ import { ChromiumLaunchHandle } from './chromiumLauncher';
24
+ import { CdpClient, CdpTransportLike } from './cdpClient';
25
+ export interface BrowserHostLease {
26
+ cdp: CdpClient;
27
+ /** Claims the page target Chromium opened at launch, once, for the first
28
+ * tab that asks. Null when it is already taken (or there is none) — the
29
+ * caller then creates its own target. */
30
+ takeSparePage(): Promise<string | null>;
31
+ /** Fires once if the shared process/connection dies under this lease. */
32
+ onClosed(cb: (reason: string) => void): void;
33
+ /** Gives the lease back. The last release kills Chromium. Idempotent. */
34
+ release(): void;
35
+ }
36
+ export interface BrowserHost {
37
+ acquire(): Promise<BrowserHostLease>;
38
+ /** Live leases (== open tabs). For tests and the summary log. */
39
+ readonly leaseCount: number;
40
+ }
41
+ export interface BrowserHostOpts {
42
+ chromiumPath?: string;
43
+ userDataDir?: string;
44
+ width?: number;
45
+ height?: number;
46
+ launchChromiumFn?: (opts: {
47
+ chromiumPath?: string;
48
+ userDataDir?: string;
49
+ width?: number;
50
+ height?: number;
51
+ }) => ChromiumLaunchHandle;
52
+ wsFactory: (url: string) => CdpTransportLike;
53
+ }
54
+ export declare function createBrowserHost(opts: BrowserHostOpts): BrowserHost;
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createBrowserHost = createBrowserHost;
4
+ /**
5
+ * cvt-07 — the box's ONE headless Chromium, shared by every Browser pane.
6
+ *
7
+ * (docs/prd/canvas-tools.md module 7, issues/cvt-07-cloud-browser-tabs.md)
8
+ * Before cvt-07 each browser driver launched its own Chromium, and the arena
9
+ * only ever opened one session per box (`browser:<containerId>`), so two
10
+ * cloud Browser panes fought over one page. Now every Browser pane is its own
11
+ * relay session (`browser:<containerId>:<paneId>`) and its own TAB (CDP page
12
+ * target) in this one process: same box, same cost, one profile.
13
+ *
14
+ * This module owns only the PROCESS + the browser-level CDP connection,
15
+ * reference-counted by lease:
16
+ * - the first acquire() launches Chromium and connects; concurrent
17
+ * acquires during the launch share it;
18
+ * - each lease may claim the spare `about:blank` page Chromium starts with
19
+ * (so the first tab doesn't leave an idle extra renderer behind);
20
+ * - the last release() kills Chromium (exactly what a single driver's
21
+ * stop()/sleep() did before — no orphaned process, no idle memory);
22
+ * - a Chromium exit / CDP close tells EVERY live lease (each tab goes to
23
+ * its terminal state), and the next acquire() relaunches.
24
+ * Targets, screencasts and input stay in browserDriver.ts — one driver per tab.
25
+ */
26
+ const chromiumLauncher_1 = require("./chromiumLauncher");
27
+ const cdpClient_1 = require("./cdpClient");
28
+ function createBrowserHost(opts) {
29
+ const launchChromiumFn = opts.launchChromiumFn ?? chromiumLauncher_1.launchChromium;
30
+ let running = null;
31
+ let launching = null;
32
+ const die = (r, reason) => {
33
+ if (r.dead)
34
+ return;
35
+ r.dead = true;
36
+ if (running === r)
37
+ running = null;
38
+ const leases = [...r.leases];
39
+ r.leases.clear();
40
+ for (const l of leases) {
41
+ l.released = true;
42
+ for (const cb of l.closedCbs)
43
+ cb(reason);
44
+ }
45
+ };
46
+ const kill = (r) => {
47
+ if (r.dead)
48
+ return;
49
+ r.dead = true;
50
+ if (running === r)
51
+ running = null;
52
+ try {
53
+ r.cdp.close();
54
+ }
55
+ catch { /* best-effort */ }
56
+ try {
57
+ r.proc.kill();
58
+ }
59
+ catch { /* best-effort */ }
60
+ };
61
+ async function launch() {
62
+ const { proc, wsUrl } = launchChromiumFn({
63
+ chromiumPath: opts.chromiumPath,
64
+ userDataDir: opts.userDataDir,
65
+ width: opts.width,
66
+ height: opts.height,
67
+ });
68
+ // Registered before awaiting wsUrl so an exit during startup is seen.
69
+ let r = null;
70
+ let earlyExit = null;
71
+ proc.on('exit', (code, signal) => {
72
+ const reason = `chromium-exit code=${code ?? 'null'} signal=${signal ?? 'null'}`;
73
+ if (r)
74
+ die(r, reason);
75
+ else
76
+ earlyExit = reason;
77
+ });
78
+ proc.on('error', (err) => {
79
+ const reason = `chromium-error ${err instanceof Error ? err.message : String(err)}`;
80
+ if (r)
81
+ die(r, reason);
82
+ else
83
+ earlyExit = reason;
84
+ });
85
+ const url = await wsUrl;
86
+ if (earlyExit)
87
+ throw new Error(earlyExit);
88
+ const cdp = (0, cdpClient_1.createCdpClient)(opts.wsFactory(url));
89
+ r = { proc, cdp, spareTaken: false, leases: new Set(), dead: false };
90
+ const self = r;
91
+ cdp.onClose((info) => die(self, info?.reason ? `cdp-closed:${info.reason}` : 'cdp-closed'));
92
+ return r;
93
+ }
94
+ async function acquire() {
95
+ let r = running && !running.dead ? running : null;
96
+ if (!r) {
97
+ if (!launching) {
98
+ launching = launch().finally(() => { launching = null; });
99
+ }
100
+ r = await launching;
101
+ if (r.dead)
102
+ throw new Error('chromium exited during launch');
103
+ running = r;
104
+ }
105
+ const rec = { closedCbs: [], released: false };
106
+ r.leases.add(rec);
107
+ const owner = r;
108
+ return {
109
+ cdp: owner.cdp,
110
+ async takeSparePage() {
111
+ if (owner.spareTaken || owner.dead)
112
+ return null;
113
+ owner.spareTaken = true;
114
+ const { targetInfos } = await owner.cdp.send('Target.getTargets');
115
+ return targetInfos.find((t) => t.type === 'page')?.targetId ?? null;
116
+ },
117
+ onClosed(cb) {
118
+ rec.closedCbs.push(cb);
119
+ },
120
+ release() {
121
+ if (rec.released)
122
+ return;
123
+ rec.released = true;
124
+ owner.leases.delete(rec);
125
+ if (owner.leases.size === 0)
126
+ kill(owner);
127
+ },
128
+ };
129
+ }
130
+ return {
131
+ acquire,
132
+ get leaseCount() {
133
+ return running && !running.dead ? running.leases.size : 0;
134
+ },
135
+ };
136
+ }
@@ -67,7 +67,13 @@ const path = __importStar(require("path"));
67
67
  /** Claude Code's project-directory naming rule — see module doc for the
68
68
  * empirical verification. */
69
69
  function claudeProjectSlug(cwd) {
70
- return cwd.replace(/[^a-zA-Z0-9]/g, '-');
70
+ // Claude Code names the folder after the cwd WITHOUT a trailing slash. The
71
+ // local folder registry stores paths with one ("…/MyProject/"), which made
72
+ // the slug end in "-", missed the agent's transcript, and launched
73
+ // `--session-id <existing id>` → "Session ID … is already in use" on every
74
+ // reopen (2026-10-04). Normalise first.
75
+ const normalized = cwd.length > 1 ? cwd.replace(/\/+$/, '') : cwd;
76
+ return normalized.replace(/[^a-zA-Z0-9]/g, '-');
71
77
  }
72
78
  /** Where Claude Code keeps its config + session files: `$CLAUDE_CONFIG_DIR`
73
79
  * when set (the hosted image's persistent-volume path), else `~/.claude`
@@ -27,6 +27,14 @@ export declare const SESSION_NOT_RELAY_OWNED = "SESSION_NOT_RELAY_OWNED";
27
27
  * terminal it did not start — the test names each one for the same reason.
28
28
  */
29
29
  export declare const SESSION_ADDRESSED_FRAMES: ReadonlySet<string>;
30
+ /** cvt-07: what this bridge advertises in its auth frame. `browser-tabs` = a
31
+ * distinct browser sessionId is a distinct TAB in one shared Chromium, and
32
+ * `browser-close`/`browser-reload` are understood. The relay keeps old
33
+ * images (no caps) on the single default session. */
34
+ export declare const BRIDGE_CAP_BROWSER_TABS = "browser-tabs";
35
+ /** cvt-07: max live browser tabs per box — the arena's Browser-pane cap (4
36
+ * per canvas). A fifth open evicts the tab nobody has looked at longest. */
37
+ export declare const DEFAULT_MAX_BROWSER_TABS = 4;
30
38
  /** pucc-04 workspace-scoped git credential, resolved + injected by the relay. */
31
39
  export interface InjectedGitCredential {
32
40
  token: string;
@@ -594,6 +602,11 @@ export interface RelayClientOpts {
594
602
  * idempotent pause() calls — see browserDriver.pause()'s doc comment),
595
603
  * so a short cadence costs nothing. Default 5_000ms. */
596
604
  browserConsumerCheckIntervalMs?: number;
605
+ /** cvt-07: capabilities advertised to the relay in the auth frame (e.g.
606
+ * BRIDGE_CAP_BROWSER_TABS). Absent → none (the pre-cvt-07 auth frame). */
607
+ capabilities?: readonly string[];
608
+ /** cvt-07: max live browser tabs (default DEFAULT_MAX_BROWSER_TABS). */
609
+ maxBrowserTabs?: number;
597
610
  /** Test/observability hooks. */
598
611
  onConnect?: () => void;
599
612
  /**
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.SESSION_ADDRESSED_FRAMES = exports.SESSION_NOT_RELAY_OWNED = exports.LAUNCH_COMMAND_MISSING = void 0;
36
+ exports.DEFAULT_MAX_BROWSER_TABS = exports.BRIDGE_CAP_BROWSER_TABS = exports.SESSION_ADDRESSED_FRAMES = exports.SESSION_NOT_RELAY_OWNED = exports.LAUNCH_COMMAND_MISSING = void 0;
37
37
  exports.assembleSessionEnv = assembleSessionEnv;
38
38
  exports.formatHealthLine = formatHealthLine;
39
39
  exports.formatDisconnectLine = formatDisconnectLine;
@@ -136,7 +136,16 @@ exports.SESSION_ADDRESSED_FRAMES = new Set([
136
136
  'browser-input', 'browser-frame-ack', 'browser-resize', 'browser-sleep',
137
137
  // Terminal attach writes next to a session — same consent boundary.
138
138
  'attach-file',
139
+ 'browser-close', 'browser-reload',
139
140
  ]);
141
+ /** cvt-07: what this bridge advertises in its auth frame. `browser-tabs` = a
142
+ * distinct browser sessionId is a distinct TAB in one shared Chromium, and
143
+ * `browser-close`/`browser-reload` are understood. The relay keeps old
144
+ * images (no caps) on the single default session. */
145
+ exports.BRIDGE_CAP_BROWSER_TABS = 'browser-tabs';
146
+ /** cvt-07: max live browser tabs per box — the arena's Browser-pane cap (4
147
+ * per canvas). A fifth open evicts the tab nobody has looked at longest. */
148
+ exports.DEFAULT_MAX_BROWSER_TABS = 4;
140
149
  /**
141
150
  * pucc-14 — assemble the per-session PTY env for a workspace-scoped `open`.
142
151
  *
@@ -507,6 +516,29 @@ function startRelayClient(opts) {
507
516
  function sleepBrowserSession(sessionId, reason) {
508
517
  browserDrivers.get(sessionId)?.sleep(reason);
509
518
  }
519
+ /** cvt-07: before opening one more tab at the cap, put the tab with the
520
+ * stalest consumer signal to sleep (its pane shows asleep + Wake). */
521
+ function evictBrowserTabIfFull() {
522
+ const max = opts.maxBrowserTabs ?? exports.DEFAULT_MAX_BROWSER_TABS;
523
+ while (browserDrivers.size >= max) {
524
+ let victim = null;
525
+ let oldest = Infinity;
526
+ for (const sid of browserDrivers.keys()) {
527
+ const at = browserLastConsumerAt.get(sid) ?? -Infinity;
528
+ if (at < oldest) {
529
+ oldest = at;
530
+ victim = sid;
531
+ }
532
+ }
533
+ if (!victim)
534
+ return;
535
+ sleepBrowserSession(victim, 'tab-evicted');
536
+ // A driver whose sleep didn't retire it synchronously must not wedge
537
+ // this loop.
538
+ if (browserDrivers.has(victim))
539
+ browserDrivers.delete(victim);
540
+ }
541
+ }
510
542
  // pucc-14 login_valid report. Container-level (not per session): the ONE
511
543
  // shared login on the volume. `lastLoginValid` persists across reconnects so
512
544
  // the poll only re-emits on a genuine change; each fresh connect force-emits
@@ -837,7 +869,11 @@ function startRelayClient(opts) {
837
869
  };
838
870
  socket.on('pong', markLinkAlive);
839
871
  socket.on('open', () => {
840
- send(socket, { v: PROTOCOL_VERSION, relayToken: opts.relayToken });
872
+ send(socket, {
873
+ v: PROTOCOL_VERSION,
874
+ relayToken: opts.relayToken,
875
+ ...(opts.capabilities?.length ? { caps: [...opts.capabilities] } : {}),
876
+ });
841
877
  connected = true;
842
878
  startHeartbeat();
843
879
  // twr-06: `attempt` reset + `onConnect` moved OFF this handler — the
@@ -1595,6 +1631,7 @@ function startRelayClient(opts) {
1595
1631
  }
1596
1632
  break;
1597
1633
  }
1634
+ evictBrowserTabIfFull(); // cvt-07
1598
1635
  const driver = opts.createBrowserDriver({ sessionId });
1599
1636
  browserDrivers.set(sessionId, driver);
1600
1637
  browserDriverUrls.set(sessionId, url);
@@ -1728,6 +1765,21 @@ function startRelayClient(opts) {
1728
1765
  // the map by the first sleep's onState handler above).
1729
1766
  sleepBrowserSession(frame.sessionId, 'sleep');
1730
1767
  break;
1768
+ case 'browser-close':
1769
+ // cvt-07: the pane was closed — close its tab (stop(): terminal
1770
+ // 'closed', and in shared-host mode only this target goes; the
1771
+ // host kills Chromium when the last tab closes). Safe no-op for
1772
+ // an unknown session.
1773
+ browserDrivers.get(frame.sessionId)?.stop();
1774
+ break;
1775
+ case 'browser-reload':
1776
+ // cvt-07: reload exactly this tab. A live consumer asked, so it
1777
+ // also counts as one (and un-pauses capture to show the result).
1778
+ if (browserLastConsumerAt.has(frame.sessionId))
1779
+ browserLastConsumerAt.set(frame.sessionId, now());
1780
+ browserDrivers.get(frame.sessionId)?.resume().catch(() => { });
1781
+ browserDrivers.get(frame.sessionId)?.reload().catch(() => { });
1782
+ break;
1731
1783
  case 'companion-allow': {
1732
1784
  // bws-08 — session-less tenant-boundary control frame (see
1733
1785
  // companionFrames.ts's doc comment). Validated with the runtime
@@ -63,6 +63,7 @@ const sessionManager_1 = require("../bridge/sessionManager");
63
63
  const server_1 = require("../bridge/server");
64
64
  const relayClient_1 = require("../bridge/relayClient");
65
65
  const browserDriver_1 = require("../bridge/browserDriver");
66
+ const browserHost_1 = require("../bridge/browserHost");
66
67
  const browserIdle_1 = require("../bridge/browserIdle");
67
68
  const loginStatus_1 = require("../bridge/loginStatus");
68
69
  const realPty_1 = require("../bridge/realPty");
@@ -571,6 +572,8 @@ exports.bridgeCommand = new commander_1.Command('bridge')
571
572
  // does not).
572
573
  rm: rmCheckoutDir,
573
574
  });
575
+ // cvt-07: the box's one shared Chromium, created lazily by the first tab.
576
+ let browserHost;
574
577
  (0, relayClient_1.startRelayClient)({
575
578
  relayUrl: relayMode.relayUrl,
576
579
  relayToken: relayMode.relayToken,
@@ -866,10 +869,18 @@ exports.bridgeCommand = new commander_1.Command('bridge')
866
869
  // either way. An old image without Chromium (or any other launch
867
870
  // failure) therefore degrades to "that one browser session failed to
868
871
  // open" — never a bridge crash, never a broken terminal pane.
872
+ //
873
+ // cvt-07: every browser session is a TAB in ONE shared Chromium
874
+ // (browserHost.ts) — one process, one profile, launched by the first
875
+ // tab and killed when the last one closes. Advertised to the relay as
876
+ // BRIDGE_CAP_BROWSER_TABS below so it may give each Browser pane its
877
+ // own session.
869
878
  createBrowserDriver: () => {
870
879
  const { chromiumPath, userDataDir } = buildBrowserDriverOptions();
871
- return (0, browserDriver_1.createBrowserDriver)({ chromiumPath, userDataDir });
880
+ browserHost ??= (0, browserHost_1.createBrowserHost)({ chromiumPath, userDataDir, wsFactory: (u) => (0, browserDriver_1.createDefaultTransport)(u) });
881
+ return (0, browserDriver_1.createBrowserDriver)({ chromiumPath, userDataDir, host: browserHost });
872
882
  },
883
+ capabilities: [relayClient_1.BRIDGE_CAP_BROWSER_TABS],
873
884
  // bws-08 — the companion port forwarder (docs/prd/
874
885
  // browser-with-sound-in-the-container.md D10). Constructed ONCE,
875
886
  // same injection posture as createBrowserDriver above: relayClient.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wenrwa/marketplace-cli",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "CLI tool for the Wenrwa Agent Marketplace \u2014 monitor bounties, agents, workspaces, and treasury from your terminal",
5
5
  "author": "Wenrwa <dev@wenrwa.com>",
6
6
  "license": "MIT",