@yeaft/webchat-agent 1.0.415 → 1.0.417

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.
@@ -1,22 +1,110 @@
1
- import { randomUUID } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { normaliseBrowserRuntimeSection } from './config.js';
3
3
  import { defaultBrowserCacheDir } from './browser-install.js';
4
4
  import { probeBrowserRuntime } from './probe.js';
5
5
  import { BrowserRuntimeError } from './errors.js';
6
+ import { BrowserExtensionBridge } from './local-bridge.js';
7
+ import { launchBrowserSession } from './chromium.js';
6
8
 
7
- /**
8
- * Agent-local Browser Runtime lifecycle owner. Phase 0 exposes startup probe and
9
- * capacity semantics only; Browser Sessions remain unavailable until the later
10
- * owner-checked control-plane phase lands.
11
- */
9
+ const REQUEST_TTL_MS = 10 * 60_000;
10
+
11
+ function clean(value, max = 512) {
12
+ return typeof value === 'string' ? value.trim().slice(0, max) : '';
13
+ }
14
+
15
+ function positiveInteger(value) {
16
+ return Number.isSafeInteger(value) && value > 0;
17
+ }
18
+
19
+ function safeInitialUrl(value) {
20
+ const raw = clean(value, 4096) || 'about:blank';
21
+ if (raw === 'about:blank') return raw;
22
+ try {
23
+ const url = new URL(raw);
24
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
25
+ throw new BrowserRuntimeError('browser_url_invalid');
26
+ }
27
+ return url.href;
28
+ } catch (error) {
29
+ if (error instanceof BrowserRuntimeError) throw error;
30
+ throw new BrowserRuntimeError('browser_url_invalid');
31
+ }
32
+ }
33
+
34
+ function stableDigest(value) {
35
+ return createHash('sha256').update(JSON.stringify(value || null)).digest('hex');
36
+ }
37
+
38
+ function normalizeIdentity(value) {
39
+ const identity = value && typeof value === 'object' ? value : {};
40
+ const result = Object.freeze({
41
+ ownerUserId: clean(identity.ownerUserId),
42
+ clientId: clean(identity.clientId),
43
+ webConnectionId: clean(identity.webConnectionId),
44
+ webConnectionGeneration: clean(identity.webConnectionGeneration),
45
+ });
46
+ if (!result.ownerUserId || !result.clientId || !result.webConnectionId || !result.webConnectionGeneration) {
47
+ throw new BrowserRuntimeError('browser_identity_required');
48
+ }
49
+ return result;
50
+ }
51
+
52
+ function sameOwner(a, b) {
53
+ return a?.ownerUserId === b?.ownerUserId;
54
+ }
55
+
56
+ function sameConnection(a, b) {
57
+ return sameOwner(a, b)
58
+ && a?.clientId === b?.clientId
59
+ && a?.webConnectionId === b?.webConnectionId
60
+ && a?.webConnectionGeneration === b?.webConnectionGeneration;
61
+ }
62
+
63
+ function publicIceServers(value) {
64
+ if (!Array.isArray(value)) return [];
65
+ return value.slice(0, 8).map(server => ({
66
+ urls: Array.isArray(server?.urls)
67
+ ? server.urls.slice(0, 8).map(url => clean(url, 2048)).filter(Boolean)
68
+ : clean(server?.urls, 2048),
69
+ ...(clean(server?.username, 512) ? { username: clean(server.username, 512) } : {}),
70
+ ...(clean(server?.credential, 1024) ? { credential: clean(server.credential, 1024) } : {}),
71
+ })).filter(server => Array.isArray(server.urls) ? server.urls.length > 0 : !!server.urls);
72
+ }
73
+
74
+ function sourceRef(value) {
75
+ if (!value || typeof value !== 'object') return null;
76
+ const kind = ['yeaft-session', 'chat-conversation', 'work-item'].includes(value.kind) ? value.kind : null;
77
+ if (!kind) return null;
78
+ return Object.freeze({
79
+ kind,
80
+ ...(clean(value.sessionId) ? { sessionId: clean(value.sessionId) } : {}),
81
+ ...(clean(value.conversationId) ? { conversationId: clean(value.conversationId) } : {}),
82
+ ...(clean(value.workItemId) ? { workItemId: clean(value.workItemId) } : {}),
83
+ });
84
+ }
85
+
86
+ /** Agent-local owner for Browser Session, Chromium and WebRTC peer lifecycles. */
12
87
  export class BrowserRuntimeService {
13
- constructor({ yeaftDir, config, probe = probeBrowserRuntime } = {}) {
88
+ constructor({
89
+ yeaftDir,
90
+ config,
91
+ probe = probeBrowserRuntime,
92
+ bridge = new BrowserExtensionBridge(),
93
+ launchSession = launchBrowserSession,
94
+ send = null,
95
+ platform = process.platform,
96
+ } = {}) {
14
97
  if (!yeaftDir) throw new Error('yeaftDir required');
15
98
  this.yeaftDir = yeaftDir;
16
99
  this.config = normaliseBrowserRuntimeSection(config);
17
100
  this.config.cacheDir ||= defaultBrowserCacheDir(yeaftDir);
18
101
  this.probe = probe;
102
+ this.bridge = bridge;
103
+ this.launchSession = launchSession;
104
+ this.send = typeof send === 'function' ? send : () => 'dropped';
105
+ this.platform = platform;
19
106
  this.sessions = new Map();
107
+ this.requests = new Map();
20
108
  this.probeResult = null;
21
109
  this.state = this.config.enabled ? 'unprobed' : 'disabled';
22
110
  this.#probePromise = null;
@@ -32,9 +120,8 @@ export class BrowserRuntimeService {
32
120
  get ready() { return this.state === 'ready' && this.probeResult?.ok === true; }
33
121
 
34
122
  capabilities() {
35
- // Phase 0 is a local spike only. Advertising the Phase 1 feature before
36
- // owner-checked create/attach/signaling exists would expose a dead route.
37
- return [];
123
+ if (!this.ready || this.platform !== 'linux' || this.probeResult?.captureMode !== 'tab') return [];
124
+ return ['browser_runtime', 'browser_webrtc', 'browser_capture_tab'];
38
125
  }
39
126
 
40
127
  async startupProbe() {
@@ -65,29 +152,458 @@ export class BrowserRuntimeService {
65
152
  return this.#probePromise;
66
153
  }
67
154
 
155
+ #emit(message) {
156
+ return this.send(message);
157
+ }
158
+
159
+ #pruneRequests(now = Date.now()) {
160
+ for (const [key, request] of this.requests) {
161
+ if (request.expiresAt <= now) this.requests.delete(key);
162
+ }
163
+ }
164
+
165
+ #requestKey(identity, requestId) {
166
+ return `${identity.ownerUserId}\0${identity.webConnectionId}\0${clean(requestId)}`;
167
+ }
168
+
169
+ #snapshot(session, extra = {}) {
170
+ return {
171
+ browserSessionId: session.browserSessionId,
172
+ revision: session.revision,
173
+ state: session.state,
174
+ activeUrl: session.activeUrl,
175
+ title: session.title,
176
+ pageRevision: session.pageRevision,
177
+ captureMode: session.captureMode,
178
+ viewport: session.viewport,
179
+ viewerCount: session.peers.size,
180
+ interactivePeerCount: 0,
181
+ authorizedProducerCount: 0,
182
+ expiresAt: session.expiresAt,
183
+ terminalReason: session.terminalReason || null,
184
+ safeError: session.safeError || null,
185
+ sourceRef: session.sourceRef,
186
+ ...extra,
187
+ };
188
+ }
189
+
190
+ #emitSnapshot(session, extra = {}) {
191
+ return this.#emit({ type: 'browser_session_snapshot', ...this.#snapshot(session, extra) });
192
+ }
193
+
194
+ #dropPeer(session, peer, reason = 'peer_closed') {
195
+ if (!peer || session.peers.get(peer.peerId) !== peer) return false;
196
+ clearTimeout(peer.expiryTimer);
197
+ peer.expiryTimer = null;
198
+ session.peers.delete(peer.peerId);
199
+ this.bridge.send(session.browserSessionId, {
200
+ type: 'peer_close',
201
+ peerId: peer.peerId,
202
+ connectionGeneration: peer.connectionGeneration,
203
+ reason,
204
+ });
205
+ session.revision += 1;
206
+ this.#scheduleNoViewerCleanup(session);
207
+ return true;
208
+ }
209
+
210
+ #scheduleNoViewerCleanup(session) {
211
+ clearTimeout(session.noViewerTimer);
212
+ session.noViewerTimer = null;
213
+ if (session.state !== 'ready' || session.peers.size > 0) return;
214
+ const delay = Math.max(10_000, Number(this.config.noViewerIdleMs) || 120_000);
215
+ session.expiresAt = Date.now() + delay;
216
+ session.noViewerTimer = setTimeout(() => {
217
+ if (this.sessions.get(session.browserSessionId) === session && session.peers.size === 0) {
218
+ void this.closeSessionRecord(session, 'no_viewer_timeout');
219
+ }
220
+ }, delay);
221
+ session.noViewerTimer.unref?.();
222
+ }
223
+
68
224
  assertCanCreateSession() {
69
225
  if (!this.ready) throw new BrowserRuntimeError('browser_runtime_unavailable');
70
- if (this.sessions.size >= this.config.maxSessions) {
71
- throw new BrowserRuntimeError('browser_session_limit');
72
- }
226
+ if (this.sessions.size >= this.config.maxSessions) throw new BrowserRuntimeError('browser_session_limit');
73
227
  }
74
228
 
75
- /** Phase 0 test hook for lifecycle/capacity ownership. */
76
- reserveSession(ownerUserId) {
229
+ async createSession(message) {
230
+ const identity = normalizeIdentity(message?.serverIdentity);
231
+ const requestId = clean(message?.requestId);
232
+ if (!requestId) throw new BrowserRuntimeError('browser_request_id_required');
233
+ const options = {
234
+ initialUrl: safeInitialUrl(message?.options?.initialUrl),
235
+ viewport: message?.options?.viewport || null,
236
+ locale: clean(message?.options?.locale, 32) || 'en-US',
237
+ capturePreference: clean(message?.options?.capturePreference, 16) || 'auto',
238
+ };
239
+ if (!['auto', 'tab'].includes(options.capturePreference)) {
240
+ throw new BrowserRuntimeError('browser_capture_mode_unsupported');
241
+ }
242
+ this.#pruneRequests();
243
+ const requestKey = this.#requestKey(identity, requestId);
244
+ const digest = stableDigest({ options, sourceRef: sourceRef(message?.sourceRef) });
245
+ const existing = this.requests.get(requestKey);
246
+ if (existing) {
247
+ if (existing.digest !== digest) throw new BrowserRuntimeError('browser_request_conflict');
248
+ return existing.promise;
249
+ }
77
250
  this.assertCanCreateSession();
78
- if (!ownerUserId) throw new BrowserRuntimeError('browser_owner_required');
251
+
79
252
  const browserSessionId = randomUUID();
80
- this.sessions.set(browserSessionId, Object.freeze({
253
+ const session = {
81
254
  browserSessionId,
82
- ownerUserId,
83
- state: 'reserved',
84
- createdAt: Date.now(),
85
- }));
86
- return this.sessions.get(browserSessionId);
255
+ owner: identity,
256
+ sourceRef: sourceRef(message?.sourceRef),
257
+ revision: 1,
258
+ pageRevision: 1,
259
+ state: 'starting',
260
+ activeUrl: options.initialUrl,
261
+ title: '',
262
+ captureMode: 'tab',
263
+ viewport: null,
264
+ peers: new Map(),
265
+ runtime: null,
266
+ bridgeRegistration: null,
267
+ noViewerTimer: null,
268
+ expiresAt: null,
269
+ terminalReason: null,
270
+ safeError: null,
271
+ closingPromise: null,
272
+ startupAbort: new AbortController(),
273
+ };
274
+ this.sessions.set(browserSessionId, session);
275
+ const promise = this.#startSession(session, options, requestId);
276
+ this.requests.set(requestKey, { digest, promise, expiresAt: Date.now() + REQUEST_TTL_MS });
277
+ return promise;
87
278
  }
88
279
 
89
- releaseSession(browserSessionId) {
90
- return this.sessions.delete(browserSessionId);
280
+ async #startSession(session, options, requestId) {
281
+ try {
282
+ session.bridgeRegistration = await this.bridge.registerSession(session.browserSessionId, {
283
+ onMessage: message => this.#handleBridgeMessage(session, message),
284
+ onDisconnect: () => {
285
+ if (session.state === 'ready') void this.closeSessionRecord(session, 'extension_disconnected');
286
+ },
287
+ });
288
+ session.runtime = await this.launchSession({
289
+ browserSessionId: session.browserSessionId,
290
+ bridgeUrl: session.bridgeRegistration.bridgeUrl,
291
+ config: this.config,
292
+ initialUrl: options.initialUrl,
293
+ viewport: options.viewport,
294
+ locale: options.locale,
295
+ signal: session.startupAbort.signal,
296
+ });
297
+ if (this.sessions.get(session.browserSessionId) !== session || session.state !== 'starting') {
298
+ await session.runtime.close?.().catch(() => {});
299
+ session.runtime = null;
300
+ throw new BrowserRuntimeError('browser_session_cancelled');
301
+ }
302
+ await session.bridgeRegistration.waitUntilReady(this.config.startupProbeTimeoutMs);
303
+ if (this.sessions.get(session.browserSessionId) !== session || session.state !== 'starting') {
304
+ throw new BrowserRuntimeError('browser_session_cancelled');
305
+ }
306
+ session.viewport = session.runtime.viewport;
307
+ session.captureMode = session.runtime.captureMode;
308
+ session.activeUrl = session.runtime.page.url();
309
+ session.title = await session.runtime.page.title().catch(() => '');
310
+ session.state = 'ready';
311
+ session.revision += 1;
312
+ session.runtime.page.on('framenavigated', frame => {
313
+ if (frame !== session.runtime?.page?.mainFrame?.()) return;
314
+ session.pageRevision += 1;
315
+ session.revision += 1;
316
+ session.activeUrl = frame.url();
317
+ void session.runtime.page.title().then(title => { session.title = title; }).catch(() => {});
318
+ this.#emitSnapshot(session);
319
+ });
320
+ session.runtime.page.on('close', () => {
321
+ if (session.state === 'ready') void this.closeSessionRecord(session, 'page_closed');
322
+ });
323
+ this.#scheduleNoViewerCleanup(session);
324
+ const created = {
325
+ type: 'browser_session_created',
326
+ requestId,
327
+ ...this.#snapshot(session),
328
+ };
329
+ await this.#emit(created);
330
+ return created;
331
+ } catch (error) {
332
+ session.state = 'failed';
333
+ session.revision += 1;
334
+ session.terminalReason = error?.code || 'browser_session_start_failed';
335
+ session.safeError = String(error?.message || error).slice(0, 500);
336
+ await this.#emit({
337
+ type: 'browser_session_error',
338
+ requestId,
339
+ browserSessionId: session.browserSessionId,
340
+ code: session.terminalReason,
341
+ safeError: session.safeError,
342
+ });
343
+ await this.closeSessionRecord(session, session.terminalReason, { emit: false });
344
+ throw error;
345
+ }
346
+ }
347
+
348
+ #sessionFor(message, { exactConnection = false } = {}) {
349
+ const session = this.sessions.get(clean(message?.browserSessionId));
350
+ if (!session) throw new BrowserRuntimeError('browser_session_not_found');
351
+ const identity = normalizeIdentity(message?.serverIdentity);
352
+ if (!sameOwner(session.owner, identity)) throw new BrowserRuntimeError('browser_owner_mismatch');
353
+ if (exactConnection && !sameConnection(session.owner, identity)) {
354
+ // Peer messages are checked against the peer's connection below. Session
355
+ // creation ownership alone must not grant a sibling browser tab control.
356
+ }
357
+ return { session, identity };
358
+ }
359
+
360
+ async preparePeer(message) {
361
+ const { session, identity } = this.#sessionFor(message);
362
+ if (session.state !== 'ready') throw new BrowserRuntimeError('browser_session_unavailable');
363
+ const peerId = clean(message?.peerId);
364
+ const connectionGeneration = Number(message?.connectionGeneration);
365
+ if (!peerId || !positiveInteger(connectionGeneration)) throw new BrowserRuntimeError('browser_peer_invalid');
366
+ const existing = session.peers.get(peerId);
367
+ if (existing) {
368
+ if (existing.connectionGeneration !== connectionGeneration || !sameConnection(existing.identity, identity)) {
369
+ throw new BrowserRuntimeError('browser_peer_conflict');
370
+ }
371
+ if (existing.state === 'prepared' || existing.state === 'offered' || existing.state === 'connected') {
372
+ return this.#emit({
373
+ type: 'browser_peer_prepared',
374
+ browserSessionId: session.browserSessionId,
375
+ peerId,
376
+ connectionGeneration,
377
+ });
378
+ }
379
+ return true;
380
+ }
381
+ if (session.peers.size >= this.config.maxPeersPerSession) throw new BrowserRuntimeError('browser_peer_limit');
382
+ const peer = {
383
+ peerId,
384
+ connectionGeneration,
385
+ identity,
386
+ state: 'preparing',
387
+ webCandidateCount: 0,
388
+ agentCandidateCount: 0,
389
+ expiresAt: Number(message?.routeExpiresAt) || Date.now() + 10 * 60_000,
390
+ expiryTimer: null,
391
+ };
392
+ session.peers.set(peerId, peer);
393
+ peer.expiryTimer = setTimeout(() => {
394
+ if (this.#dropPeer(session, peer, 'peer_route_expired')) this.#emitSnapshot(session);
395
+ }, Math.max(1, peer.expiresAt - Date.now()));
396
+ peer.expiryTimer.unref?.();
397
+ clearTimeout(session.noViewerTimer);
398
+ session.noViewerTimer = null;
399
+ session.expiresAt = null;
400
+ const sent = this.bridge.send(session.browserSessionId, {
401
+ type: 'peer_prepare',
402
+ peerId,
403
+ connectionGeneration,
404
+ iceServers: publicIceServers(message?.agentIceServers),
405
+ iceTransportPolicy: message?.iceTransportPolicy === 'relay' ? 'relay' : 'all',
406
+ maxBitrate: this.config.maxBitrate,
407
+ maxFps: this.config.maxFps,
408
+ });
409
+ if (!sent) {
410
+ clearTimeout(peer.expiryTimer);
411
+ session.peers.delete(peerId);
412
+ this.#scheduleNoViewerCleanup(session);
413
+ throw new BrowserRuntimeError('browser_extension_unavailable');
414
+ }
415
+ return true;
416
+ }
417
+
418
+ #peerFor(message) {
419
+ const { session, identity } = this.#sessionFor(message);
420
+ const peer = session.peers.get(clean(message?.peerId));
421
+ if (!peer || peer.connectionGeneration !== Number(message?.connectionGeneration)) {
422
+ throw new BrowserRuntimeError('browser_peer_stale');
423
+ }
424
+ if (!sameConnection(peer.identity, identity)) throw new BrowserRuntimeError('browser_peer_owner_mismatch');
425
+ return { session, peer };
426
+ }
427
+
428
+ answerPeer(message) {
429
+ const { session, peer } = this.#peerFor(message);
430
+ const description = message?.description;
431
+ if (description?.type !== 'answer' || typeof description.sdp !== 'string' || description.sdp.length > 96 * 1024) {
432
+ throw new BrowserRuntimeError('browser_sdp_invalid');
433
+ }
434
+ if (!this.bridge.send(session.browserSessionId, {
435
+ type: 'peer_answer',
436
+ peerId: peer.peerId,
437
+ connectionGeneration: peer.connectionGeneration,
438
+ description: { type: 'answer', sdp: description.sdp },
439
+ })) throw new BrowserRuntimeError('browser_extension_unavailable');
440
+ }
441
+
442
+ addPeerIceCandidate(message) {
443
+ const { session, peer } = this.#peerFor(message);
444
+ if (peer.webCandidateCount >= 128) throw new BrowserRuntimeError('browser_candidate_limit');
445
+ const candidate = message?.candidate;
446
+ if (candidate != null && (typeof candidate !== 'object'
447
+ || typeof candidate.candidate !== 'string'
448
+ || candidate.candidate.length > 4096)) {
449
+ throw new BrowserRuntimeError('browser_candidate_invalid');
450
+ }
451
+ peer.webCandidateCount += 1;
452
+ if (!this.bridge.send(session.browserSessionId, {
453
+ type: 'peer_ice_candidate',
454
+ peerId: peer.peerId,
455
+ connectionGeneration: peer.connectionGeneration,
456
+ candidate: candidate == null ? null : {
457
+ candidate: candidate.candidate,
458
+ sdpMid: clean(candidate.sdpMid, 256) || null,
459
+ sdpMLineIndex: Number.isInteger(candidate.sdpMLineIndex) ? candidate.sdpMLineIndex : null,
460
+ usernameFragment: clean(candidate.usernameFragment, 256) || null,
461
+ },
462
+ })) throw new BrowserRuntimeError('browser_extension_unavailable');
463
+ }
464
+
465
+ detachPeer(message, reason = 'peer_detached') {
466
+ const { session, peer } = this.#peerFor(message);
467
+ const dropped = this.#dropPeer(session, peer, reason);
468
+ if (dropped) this.#emitSnapshot(session);
469
+ return dropped;
470
+ }
471
+
472
+ async closeSession(message) {
473
+ const { session } = this.#sessionFor(message);
474
+ if (message?.expectedRevision != null && Number(message.expectedRevision) !== session.revision) {
475
+ throw new BrowserRuntimeError('browser_revision_conflict');
476
+ }
477
+ await this.closeSessionRecord(session, 'user_closed', { emit: false });
478
+ return this.#emit({
479
+ type: 'browser_session_snapshot',
480
+ requestId: clean(message?.requestId) || null,
481
+ ...this.#snapshot(session),
482
+ });
483
+ }
484
+
485
+ async closeSessionRecord(session, reason = 'closed', { emit = true } = {}) {
486
+ if (!session || this.sessions.get(session.browserSessionId) !== session) return false;
487
+ if (session.closingPromise) return session.closingPromise;
488
+ session.closingPromise = (async () => {
489
+ session.state = 'closing';
490
+ session.revision += 1;
491
+ session.startupAbort?.abort(new BrowserRuntimeError('browser_session_cancelled'));
492
+ session.terminalReason = reason;
493
+ clearTimeout(session.noViewerTimer);
494
+ session.noViewerTimer = null;
495
+ for (const peer of session.peers.values()) clearTimeout(peer.expiryTimer);
496
+ session.peers.clear();
497
+ this.bridge.send(session.browserSessionId, { type: 'session_close', reason });
498
+ this.bridge.unregisterSession(session.browserSessionId, reason);
499
+ try { await session.runtime?.close?.(); } catch {}
500
+ session.runtime = null;
501
+ if (this.sessions.get(session.browserSessionId) === session) {
502
+ this.sessions.delete(session.browserSessionId);
503
+ }
504
+ session.state = 'closed';
505
+ session.revision += 1;
506
+ session.expiresAt = null;
507
+ if (emit) await this.#emitSnapshot(session);
508
+ return true;
509
+ })();
510
+ return session.closingPromise;
511
+ }
512
+
513
+ getSession(message) {
514
+ const { session } = this.#sessionFor(message);
515
+ return this.#emit({
516
+ type: 'browser_session_snapshot',
517
+ requestId: clean(message?.requestId) || null,
518
+ ...this.#snapshot(session),
519
+ });
520
+ }
521
+
522
+ listSessions(message) {
523
+ const identity = normalizeIdentity(message?.serverIdentity);
524
+ const sessions = [...this.sessions.values()]
525
+ .filter(session => sameOwner(session.owner, identity))
526
+ .map(session => this.#snapshot(session));
527
+ return this.#emit({
528
+ type: 'browser_session_list_result',
529
+ requestId: clean(message?.requestId) || null,
530
+ sessions,
531
+ });
532
+ }
533
+
534
+ #handleBridgeMessage(session, message) {
535
+ if (this.sessions.get(session.browserSessionId) !== session) return;
536
+ if (message.type === 'peer_prepared' || message.type === 'peer_offer'
537
+ || message.type === 'peer_ice_candidate' || message.type === 'peer_state'
538
+ || message.type === 'peer_error') {
539
+ const peer = session.peers.get(clean(message.peerId));
540
+ if (!peer || peer.connectionGeneration !== Number(message.connectionGeneration)) return;
541
+ if (message.type === 'peer_prepared') {
542
+ peer.state = 'prepared';
543
+ void this.#emit({
544
+ type: 'browser_peer_prepared',
545
+ browserSessionId: session.browserSessionId,
546
+ peerId: peer.peerId,
547
+ connectionGeneration: peer.connectionGeneration,
548
+ });
549
+ } else if (message.type === 'peer_offer') {
550
+ const description = message.description;
551
+ if (description?.type !== 'offer' || typeof description.sdp !== 'string' || description.sdp.length > 96 * 1024) {
552
+ void this.closeSessionRecord(session, 'invalid_extension_offer');
553
+ return;
554
+ }
555
+ peer.state = 'offered';
556
+ void this.#emit({
557
+ type: 'browser_peer_offer',
558
+ browserSessionId: session.browserSessionId,
559
+ peerId: peer.peerId,
560
+ connectionGeneration: peer.connectionGeneration,
561
+ description: { type: 'offer', sdp: description.sdp },
562
+ });
563
+ } else if (message.type === 'peer_ice_candidate') {
564
+ if (peer.agentCandidateCount >= 128) return;
565
+ peer.agentCandidateCount += 1;
566
+ void this.#emit({
567
+ type: 'browser_peer_ice_candidate',
568
+ browserSessionId: session.browserSessionId,
569
+ peerId: peer.peerId,
570
+ connectionGeneration: peer.connectionGeneration,
571
+ candidate: message.candidate || null,
572
+ });
573
+ } else if (message.type === 'peer_error') {
574
+ this.#dropPeer(session, peer, 'peer_failed');
575
+ void this.#emit({
576
+ type: 'browser_peer_error',
577
+ browserSessionId: session.browserSessionId,
578
+ peerId: peer.peerId,
579
+ connectionGeneration: peer.connectionGeneration,
580
+ code: clean(message.code, 128) || 'peer_failed',
581
+ safeError: clean(message.safeError, 500) || 'Browser peer failed',
582
+ });
583
+ void this.#emitSnapshot(session);
584
+ } else {
585
+ const nextState = clean(message.state, 32) || peer.state;
586
+ if (nextState === 'connected') {
587
+ clearTimeout(peer.expiryTimer);
588
+ peer.expiryTimer = null;
589
+ peer.expiresAt = null;
590
+ peer.state = nextState;
591
+ } else if (['failed', 'disconnected', 'closed'].includes(nextState)) {
592
+ this.#dropPeer(session, peer, `peer_${nextState}`);
593
+ } else {
594
+ peer.state = nextState;
595
+ }
596
+ void this.#emit({
597
+ type: 'browser_peer_state',
598
+ browserSessionId: session.browserSessionId,
599
+ peerId: peer.peerId,
600
+ connectionGeneration: peer.connectionGeneration,
601
+ state: nextState,
602
+ });
603
+ }
604
+ return;
605
+ }
606
+ if (message.type === 'capture_ended') void this.closeSessionRecord(session, 'capture_ended');
91
607
  }
92
608
 
93
609
  snapshot() {
@@ -101,11 +617,18 @@ export class BrowserRuntimeService {
101
617
  });
102
618
  }
103
619
 
620
+ async handleTransportDisconnect() {
621
+ await Promise.allSettled([...this.sessions.values()]
622
+ .map(session => this.closeSessionRecord(session, 'agent_transport_disconnected', { emit: false })));
623
+ }
624
+
104
625
  async shutdown() {
105
626
  if (this.#shutdownPromise) return this.#shutdownPromise;
106
627
  this.#probeAbort?.abort(new BrowserRuntimeError('browser_runtime_shutdown'));
107
- this.#shutdownPromise = Promise.resolve(this.#probePromise).catch(() => {}).then(() => {
108
- this.sessions.clear();
628
+ this.#shutdownPromise = Promise.resolve(this.#probePromise).catch(() => {}).then(async () => {
629
+ await Promise.allSettled([...this.sessions.values()]
630
+ .map(session => this.closeSessionRecord(session, 'browser_runtime_shutdown', { emit: false })));
631
+ await this.bridge.close();
109
632
  this.state = 'closed';
110
633
  });
111
634
  return this.#shutdownPromise;
@@ -53,6 +53,7 @@ export function connect(WebSocketImpl = WebSocket) {
53
53
  if (closedTerminals > 0) {
54
54
  console.log(`[PTY] Closed ${closedTerminals} terminal(s) before Agent transport replacement`);
55
55
  }
56
+ void ctx.browserRuntime?.handleTransportDisconnect?.();
56
57
  }
57
58
  ctx.ws = socket;
58
59
 
@@ -111,6 +112,7 @@ export function connect(WebSocketImpl = WebSocket) {
111
112
  if (closedTerminals > 0) {
112
113
  console.log(`[PTY] Closed ${closedTerminals} terminal(s) after Agent transport disconnect`);
113
114
  }
115
+ void ctx.browserRuntime?.handleTransportDisconnect?.();
114
116
 
115
117
  if (code === 1008) {
116
118
  console.error('Authentication failed. Check AGENT_SECRET configuration.');
@@ -33,6 +33,7 @@ import { fetchModelsDev } from '../yeaft/llm/models-dev.js';
33
33
  import { handleYeaftSessionSend, handleYeaftAskUserAnswer, handleYeaftSubAgentPrompt, handleYeaftTaskCancel, handleYeaftModeSwitch, handleYeaftModelSwitch, resetYeaftSession, refreshLiveSessionConfig, handleYeaftLoadHistory, handleYeaftLoadHistoryOutline, handleYeaftSearchHistory, handleYeaftLoadHistoryWindow, handleYeaftLoadMoreHistory, handleYeaftAbortThread, handleYeaftAbortAll, handleYeaftAbortTurn, handleYeaftVpSubscribe, handleYeaftVpCreate, handleYeaftVpUpdate, handleYeaftVpDelete, handleYeaftVpRead, handleYeaftListSessions, handleYeaftProjectContextSync, handleYeaftProjectMutation, handleYeaftCreateSession, handleYeaftRenameSession, handleYeaftUpdateSession, handleYeaftUpdateSessionConfig, handleYeaftArchiveSession, handleYeaftDeleteSession, handleYeaftSessionAddMember, handleYeaftSessionRemoveMember, handleYeaftSessionSetDefaultVp, handleYeaftScanWorkdirSessions, handleYeaftRestoreSession, handleYeaftDreamTrigger, handleYeaftFetchToolStats, handleYeaftFetchDebugHistory, handleYeaftMcpList, handleYeaftMcpAdd, handleYeaftMcpRemove, handleYeaftMcpReload, handleYeaftPluginCatalog, broadcastLanguageChange, broadcastYeaftSessionSnapshotEager, broadcastYeaftVpSnapshotEager, preloadYeaftSkillSlashCommands } from '../yeaft/web-bridge.js';
34
34
  import { startYeaftStatusRefresh, forceRefreshYeaftStatus } from '../yeaft/status-cache.js';
35
35
  import { handleWorkCenterRequest } from '../yeaft/work-center/bridge.js';
36
+ import { handleBrowserRuntimeMessage } from '../browser-runtime/messages.js';
36
37
 
37
38
  export async function applyLlmConfigUpdate(msg, dependencies = {}) {
38
39
  const updateConfig = dependencies.updateLlmConfig || updateLlmConfig;
@@ -94,6 +95,7 @@ export function applyRegisteredTransport(msg) {
94
95
  }
95
96
 
96
97
  export async function handleMessage(msg) {
98
+ if (await handleBrowserRuntimeMessage(msg)) return;
97
99
  switch (msg.type) {
98
100
  case 'registered':
99
101
  applyRegisteredTransport(msg);
package/index.js CHANGED
@@ -164,6 +164,7 @@ async function detectCapabilities() {
164
164
  if (process.platform === 'linux') capabilities.push('work_item_attachments');
165
165
  const pty = await loadNodePty();
166
166
  if (pty) capabilities.push('terminal');
167
+ if (ctx.browserRuntime) capabilities.push(...ctx.browserRuntime.capabilities());
167
168
 
168
169
  console.log(`[Capabilities] Detected: ${capabilities.join(', ')}`);
169
170
  return capabilities;
@@ -403,6 +404,7 @@ process.on('SIGTERM', async () => {
403
404
  ctx.browserRuntime = await bootBrowserRuntime({
404
405
  yeaftDir: YEAFT_DIR,
405
406
  config: runtimeConfig,
407
+ send: message => ctx.sendToServer?.(message) || 'dropped',
406
408
  });
407
409
  const probe = ctx.browserRuntime.snapshot().probe;
408
410
  if (probe?.ok) {