@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.
- package/dist/__tests__/bridge-browser-relay.test.js +81 -1
- package/dist/__tests__/bridge-browser-tabs.test.d.ts +1 -0
- package/dist/__tests__/bridge-browser-tabs.test.js +206 -0
- package/dist/__tests__/claude-session-history.test.js +14 -0
- package/dist/bridge/browserDriver.d.ts +8 -0
- package/dist/bridge/browserDriver.js +82 -25
- package/dist/bridge/browserHost.d.ts +54 -0
- package/dist/bridge/browserHost.js +136 -0
- package/dist/bridge/claudeSessionHistory.js +7 -1
- package/dist/bridge/relayClient.d.ts +13 -0
- package/dist/bridge/relayClient.js +54 -2
- package/dist/commands/bridge.js +12 -1
- package/package.json +1 -1
|
@@ -62,7 +62,10 @@ class FakeBrowserDriver {
|
|
|
62
62
|
}
|
|
63
63
|
async back() { }
|
|
64
64
|
async forward() { }
|
|
65
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
|
357
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
package/dist/commands/bridge.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|