yunti-browser-runtime 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +256 -0
  3. package/bin/yunti-browser-runtime.js +86 -0
  4. package/docs/EXECUTION_PLAN.md +1278 -0
  5. package/docs/INSTALL.md +205 -0
  6. package/docs/PROJECT_INTENT.md +44 -0
  7. package/docs/PROJECT_STATUS.md +263 -0
  8. package/docs/PUBLISHING_BLOCKERS.md +110 -0
  9. package/docs/RELEASE.md +148 -0
  10. package/docs/ROADMAP.md +42 -0
  11. package/docs/SECURITY.md +56 -0
  12. package/docs/TOOL_GUIDE.md +69 -0
  13. package/extension/background.js +55 -0
  14. package/extension/cdp.js +582 -0
  15. package/extension/content.css +9 -0
  16. package/extension/content.js +946 -0
  17. package/extension/manifest.json +35 -0
  18. package/extension/network-monitor.js +140 -0
  19. package/extension/popup.css +66 -0
  20. package/extension/popup.html +30 -0
  21. package/extension/popup.js +55 -0
  22. package/extension/session-manager.js +332 -0
  23. package/extension/settings.js +94 -0
  24. package/extension/tool-handlers.js +1158 -0
  25. package/lib/runtime-paths.js +39 -0
  26. package/mcp/bridge-hub.js +604 -0
  27. package/mcp/http-server.js +326 -0
  28. package/mcp/json-rpc.js +35 -0
  29. package/mcp/memory.js +126 -0
  30. package/mcp/redaction.js +94 -0
  31. package/mcp/server.js +269 -0
  32. package/mcp/tools.js +1092 -0
  33. package/package.json +60 -0
  34. package/scripts/check-package-metadata.js +131 -0
  35. package/scripts/check-published-package.js +113 -0
  36. package/scripts/doctor.js +163 -0
  37. package/scripts/package-extension.js +137 -0
  38. package/scripts/print-config.js +116 -0
  39. package/scripts/release-check.js +472 -0
  40. package/skills/yunti-browser-runtime/SKILL.md +77 -0
@@ -0,0 +1,39 @@
1
+ import { homedir } from "node:os"
2
+ import { join, resolve } from "node:path"
3
+
4
+ export function yuntiHome() {
5
+ return expandHome(process.env.YUNTI_HOME || "~/.yunti_agent")
6
+ }
7
+
8
+ export function yuntiMemoryDir(userId = "") {
9
+ const userSegment = yuntiUserIdPathSegment(userId)
10
+ if (userSegment) {
11
+ return join(yuntiHome(), "users", userSegment, "memory")
12
+ }
13
+ return expandHome(
14
+ process.env.YUNTI_BROWSER_DATA_DIR || join(yuntiHome(), "memory")
15
+ )
16
+ }
17
+
18
+ export function yuntiMemoryFile(userId = "") {
19
+ return join(yuntiMemoryDir(userId), "learning-memory.json")
20
+ }
21
+
22
+ export function yuntiUserIdPathSegment(value) {
23
+ const userId = String(value || "").trim()
24
+ if (!userId || userId === "anonymous") return ""
25
+ return encodeURIComponent(userId)
26
+ }
27
+
28
+ export function yuntiVendorRoot() {
29
+ return expandHome(
30
+ process.env.YUNTI_VENDOR_DIR || join(yuntiHome(), "vendor")
31
+ )
32
+ }
33
+
34
+ export function expandHome(value) {
35
+ const raw = String(value || "").trim()
36
+ if (!raw || raw === "~") return homedir()
37
+ if (raw.startsWith("~/")) return join(homedir(), raw.slice(2))
38
+ return resolve(raw)
39
+ }
@@ -0,0 +1,604 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import { forgetLearningMemory, getLearningMemory, rememberLearning } from "./memory.js"
3
+ import {
4
+ clampNumber,
5
+ normalizeCdpEvent,
6
+ sanitizeNetworkEvent,
7
+ sanitizeUrl,
8
+ truncateText,
9
+ } from "./redaction.js"
10
+ import { toolUsageHints } from "./tools.js"
11
+
12
+ export const DEFAULT_TOOL_TIMEOUT_MS = 30_000
13
+ const MAX_NETWORK_EVENTS = 1000
14
+ const MAX_CDP_EVENTS = 2000
15
+ const MAX_CONSOLE_MESSAGES = 1000
16
+ const MAX_MEMORY_ITEMS = 500
17
+ export const DEFAULT_SESSION_TTL_MS = Number(
18
+ process.env.YUNTI_BROWSER_SESSION_TTL_MS || 90_000
19
+ )
20
+
21
+ function isoNow(ms = Date.now()) {
22
+ return new Date(ms).toISOString()
23
+ }
24
+
25
+ export function sessionRecoveryHint() {
26
+ return "Call yunti_list_browser_targets to refresh the live route inventory and use the latest browserSessionId."
27
+ }
28
+
29
+ export function staleSessionError(browserSessionId, reason = "stale or disconnected") {
30
+ return new Error(
31
+ `Yunti browser session is stale or disconnected: ${browserSessionId}. Reason: ${reason}. ${sessionRecoveryHint()}`
32
+ )
33
+ }
34
+
35
+ function stripBrowserSessionId(args) {
36
+ if (!args || typeof args !== "object" || Array.isArray(args)) return args ?? {}
37
+ const next = { ...args }
38
+ delete next.browserSessionId
39
+ delete next.userId
40
+ delete next.userName
41
+ return next
42
+ }
43
+
44
+ export function normalizeRouteUserId(value) {
45
+ const userId = String(value || "").trim()
46
+ return userId && userId !== "anonymous" ? userId : ""
47
+ }
48
+
49
+ export function requireRouteUserId(args = {}, operation = "Yunti browser tool") {
50
+ const userId = normalizeRouteUserId(args?.userId)
51
+ if (!userId) {
52
+ throw new Error(`${operation}: userId is required for Yunti browser isolation`)
53
+ }
54
+ return userId
55
+ }
56
+ export class BridgeHub {
57
+ constructor({ sessionTtlMs = DEFAULT_SESSION_TTL_MS } = {}) {
58
+ this.sessions = new Map()
59
+ this.pendingRequests = new Map()
60
+ this.networkEvents = []
61
+ this.cdpEvents = []
62
+ this.consoleMessages = []
63
+ this.nextNetworkEventId = 1
64
+ this.nextCdpEventId = 1
65
+ this.nextConsoleMsgId = 1
66
+ this.activeSessionId = null
67
+ this.activeSessionByUser = new Map()
68
+ this.sessionTtlMs = Math.max(5_000, Number(sessionTtlMs) || DEFAULT_SESSION_TTL_MS)
69
+ }
70
+
71
+ sessionExpiresAt(now = Date.now()) {
72
+ return now + this.sessionTtlMs
73
+ }
74
+
75
+ refreshSession(session, now = Date.now(), patch = {}) {
76
+ const meta = session.meta || {}
77
+ session.updatedAt = now
78
+ session.expiresAt = this.sessionExpiresAt(now)
79
+ session.staleReason = ""
80
+ session.meta = {
81
+ ...meta,
82
+ ...patch,
83
+ lastSeenAt: isoNow(now),
84
+ expiresAt: isoNow(session.expiresAt),
85
+ staleReason: "",
86
+ updatedAt: isoNow(now),
87
+ }
88
+ return session
89
+ }
90
+
91
+ markSessionStale(browserSessionId, reason = "disconnected") {
92
+ const session = this.sessions.get(browserSessionId)
93
+ if (!session) return null
94
+ session.staleReason = reason
95
+ session.meta = {
96
+ ...session.meta,
97
+ staleReason: reason,
98
+ staleAt: isoNow(),
99
+ }
100
+ this.sessions.delete(browserSessionId)
101
+ if (this.activeSessionId === browserSessionId) this.activeSessionId = null
102
+ for (const [userId, activeSessionId] of this.activeSessionByUser.entries()) {
103
+ if (activeSessionId === browserSessionId) this.activeSessionByUser.delete(userId)
104
+ }
105
+ for (const poller of session.pollers.splice(0)) {
106
+ poller({ type: "noop", id: randomUUID(), stale: true, reason })
107
+ }
108
+ return session.meta
109
+ }
110
+
111
+ cleanupExpiredSessions(now = Date.now()) {
112
+ const expired = []
113
+ for (const [browserSessionId, session] of this.sessions.entries()) {
114
+ if (session.expiresAt && session.expiresAt <= now) {
115
+ expired.push({
116
+ browserSessionId,
117
+ reason: "session heartbeat expired",
118
+ meta: this.markSessionStale(browserSessionId, "session heartbeat expired"),
119
+ })
120
+ }
121
+ }
122
+ return expired
123
+ }
124
+
125
+ getLiveSession(browserSessionId) {
126
+ const session = this.sessions.get(browserSessionId)
127
+ if (!session) return null
128
+ if (session.expiresAt && session.expiresAt <= Date.now()) {
129
+ this.markSessionStale(browserSessionId, "session heartbeat expired")
130
+ return null
131
+ }
132
+ return session
133
+ }
134
+
135
+ registerSession(meta) {
136
+ this.cleanupExpiredSessions()
137
+ const browserSessionId = String(meta.browserSessionId || "").trim()
138
+ if (!browserSessionId) throw new Error("browserSessionId is required")
139
+ const routeUserId = requireRouteUserId(meta, "browser session registration")
140
+ const existing = this.sessions.get(browserSessionId) || {
141
+ browserSessionId,
142
+ queue: [],
143
+ pollers: [],
144
+ meta: {},
145
+ updatedAt: 0,
146
+ expiresAt: 0,
147
+ staleReason: "",
148
+ }
149
+ const now = Date.now()
150
+ this.refreshSession(existing, now, {
151
+ ...meta,
152
+ browserSessionId,
153
+ userId: routeUserId,
154
+ registeredAt: meta.registeredAt || existing.meta.registeredAt || isoNow(now),
155
+ })
156
+ this.sessions.set(browserSessionId, existing)
157
+ this.activeSessionId = browserSessionId
158
+ const userId = normalizeRouteUserId(existing.meta.userId)
159
+ if (userId) this.activeSessionByUser.set(userId, browserSessionId)
160
+ return existing.meta
161
+ }
162
+
163
+ activateSession(browserSessionId, args = {}) {
164
+ this.cleanupExpiredSessions()
165
+ const session = this.getLiveSession(browserSessionId)
166
+ if (!session) {
167
+ throw staleSessionError(browserSessionId, "not registered or heartbeat expired")
168
+ }
169
+ const meta = session.meta
170
+ const routeUserId = requireRouteUserId(args, "browser session activation")
171
+ if (normalizeRouteUserId(meta.userId) !== routeUserId) {
172
+ throw new Error(`browser session is not owned by userId: ${routeUserId}`)
173
+ }
174
+ this.refreshSession(session, Date.now(), {
175
+ lastActivatedAt: isoNow(),
176
+ })
177
+ this.activeSessionId = browserSessionId
178
+ const userId = normalizeRouteUserId(meta.userId)
179
+ if (userId) this.activeSessionByUser.set(userId, browserSessionId)
180
+ return session.meta
181
+ }
182
+
183
+ listSessions(args = {}) {
184
+ this.cleanupExpiredSessions()
185
+ const userId = normalizeRouteUserId(args.userId)
186
+ const sessions = [...this.sessions.values()].filter((session) => {
187
+ if (!userId) return false
188
+ return normalizeRouteUserId(session.meta?.userId) === userId
189
+ })
190
+ return sessions.map((session) => ({
191
+ ...session.meta,
192
+ queuedRequests: session.queue.length,
193
+ pollers: session.pollers.length,
194
+ networkEvents: this.networkEvents.filter(
195
+ (event) => event.browserSessionId === session.browserSessionId
196
+ ).length,
197
+ cdpEvents: this.cdpEvents.filter(
198
+ (event) => event.browserSessionId === session.browserSessionId
199
+ ).length,
200
+ active: session.browserSessionId === this.activeSessionId,
201
+ }))
202
+ }
203
+
204
+ health(args = {}) {
205
+ this.cleanupExpiredSessions()
206
+ const userId = normalizeRouteUserId(args.userId)
207
+ const sessions = this.listSessions({ userId })
208
+ const activeSessionId = userId
209
+ ? this.activeSessionByUser.get(userId) || null
210
+ : null
211
+ return {
212
+ ok: true,
213
+ name: "yunti-browser-runtime-bridge",
214
+ activeSessionId,
215
+ sessions,
216
+ sessionCount: this.sessions.size,
217
+ }
218
+ }
219
+
220
+ sessionIdsForUser(userId) {
221
+ this.cleanupExpiredSessions()
222
+ const routeUserId = normalizeRouteUserId(userId)
223
+ if (!routeUserId) return new Set()
224
+ return new Set(
225
+ [...this.sessions.values()]
226
+ .filter((session) => normalizeRouteUserId(session.meta?.userId) === routeUserId)
227
+ .map((session) => session.browserSessionId)
228
+ )
229
+ }
230
+
231
+ listPages(args = {}) {
232
+ this.cleanupExpiredSessions()
233
+ const userId = requireRouteUserId(args, "yunti_list_pages")
234
+ const activeSessionId = this.activeSessionByUser.get(userId) || null
235
+ const sessions = [...this.sessions.values()].filter((session) => {
236
+ return normalizeRouteUserId(session.meta?.userId) === userId
237
+ })
238
+ const pages = sessions.map((session) => {
239
+ const meta = session.meta || {}
240
+ return {
241
+ browserSessionId: session.browserSessionId,
242
+ url: meta.url || "",
243
+ title: meta.title || "",
244
+ tabId: meta.tabId ?? null,
245
+ windowId: meta.windowId ?? null,
246
+ active: session.browserSessionId === activeSessionId,
247
+ userId: meta.userId || "",
248
+ displayName: meta.displayName || "",
249
+ registeredAt: meta.registeredAt || meta.updatedAt || "",
250
+ }
251
+ })
252
+ return { pages, activeSessionId }
253
+ }
254
+
255
+ selectPage(browserSessionId, args = {}) {
256
+ this.cleanupExpiredSessions()
257
+ const session = this.getLiveSession(browserSessionId)
258
+ if (!browserSessionId || !session) {
259
+ throw staleSessionError(browserSessionId, "not registered or heartbeat expired")
260
+ }
261
+ const meta = session.meta
262
+ const routeUserId = requireRouteUserId(args, "yunti_select_page")
263
+ if (normalizeRouteUserId(meta.userId) !== routeUserId) {
264
+ throw new Error(`browser session is not owned by userId: ${routeUserId}`)
265
+ }
266
+ this.refreshSession(session, Date.now(), {
267
+ lastActivatedAt: isoNow(),
268
+ })
269
+ this.activeSessionId = browserSessionId
270
+ const userId = normalizeRouteUserId(meta.userId)
271
+ if (userId) this.activeSessionByUser.set(userId, browserSessionId)
272
+ return {
273
+ browserSessionId,
274
+ url: meta.url || "",
275
+ title: meta.title || "",
276
+ tabId: meta.tabId ?? null,
277
+ active: true,
278
+ userId: meta.userId || "",
279
+ }
280
+ }
281
+
282
+ resolveSessionId(args) {
283
+ this.cleanupExpiredSessions()
284
+ const userId =
285
+ args && typeof args === "object" && !Array.isArray(args)
286
+ ? requireRouteUserId(args, "Yunti browser session routing")
287
+ : requireRouteUserId({}, "Yunti browser session routing")
288
+ const explicit =
289
+ args && typeof args === "object" && !Array.isArray(args)
290
+ ? String(args.browserSessionId || "").trim()
291
+ : ""
292
+ if (explicit) {
293
+ const session = this.getLiveSession(explicit)
294
+ if (!session) {
295
+ throw staleSessionError(explicit, "not registered or heartbeat expired")
296
+ }
297
+ if (normalizeRouteUserId(session.meta?.userId) !== userId) {
298
+ throw new Error(`browser session is not owned by userId: ${userId}`)
299
+ }
300
+ return explicit
301
+ }
302
+ const userSessionId = this.activeSessionByUser.get(userId)
303
+ if (!userSessionId) {
304
+ throw new Error(`No Yunti browser tab is connected for userId: ${userId}. ${sessionRecoveryHint()}`)
305
+ }
306
+ const session = this.getLiveSession(userSessionId)
307
+ if (!session) {
308
+ throw staleSessionError(userSessionId, "active route heartbeat expired")
309
+ }
310
+ if (normalizeRouteUserId(session.meta?.userId) !== userId) {
311
+ throw new Error(`browser session is not owned by userId: ${userId}`)
312
+ }
313
+ return userSessionId
314
+ }
315
+
316
+ async callTool(tool, args = {}, timeoutMs = DEFAULT_TOOL_TIMEOUT_MS) {
317
+ const browserSessionId = this.resolveSessionId(args)
318
+ const session = this.sessions.get(browserSessionId)
319
+ const requestId = randomUUID()
320
+ const payload = {
321
+ type: "tool_request",
322
+ id: requestId,
323
+ tool,
324
+ arguments: stripBrowserSessionId(args),
325
+ createdAt: new Date().toISOString(),
326
+ }
327
+
328
+ const promise = new Promise((resolve, reject) => {
329
+ const timer = setTimeout(() => {
330
+ this.pendingRequests.delete(requestId)
331
+ reject(new Error(`Timed out waiting for browser tool result: ${tool}`))
332
+ }, timeoutMs)
333
+ this.pendingRequests.set(requestId, { resolve, reject, timer, browserSessionId, tool })
334
+ })
335
+
336
+ const poller = session.pollers.shift()
337
+ if (poller) {
338
+ poller(payload)
339
+ } else {
340
+ session.queue.push(payload)
341
+ }
342
+ return promise
343
+ }
344
+
345
+ recordNetworkEvent(input) {
346
+ this.cleanupExpiredSessions()
347
+ const event = sanitizeNetworkEvent(input || {}, this.nextNetworkEventId++)
348
+ if (!event.browserSessionId) return { accepted: false, error: "browserSessionId is required" }
349
+ if (!this.getLiveSession(event.browserSessionId)) {
350
+ return { accepted: false, error: staleSessionError(event.browserSessionId).message }
351
+ }
352
+ this.networkEvents.push(event)
353
+ if (this.networkEvents.length > MAX_NETWORK_EVENTS) {
354
+ this.networkEvents.splice(0, this.networkEvents.length - MAX_NETWORK_EVENTS)
355
+ }
356
+ return { accepted: true, event }
357
+ }
358
+
359
+ recordCdpEvent(input) {
360
+ this.cleanupExpiredSessions()
361
+ const event = normalizeCdpEvent(input || {}, this.nextCdpEventId++)
362
+ if (!event.browserSessionId) return { accepted: false, error: "browserSessionId is required" }
363
+ if (!event.method) return { accepted: false, error: "method is required" }
364
+ if (!this.getLiveSession(event.browserSessionId)) {
365
+ return { accepted: false, error: staleSessionError(event.browserSessionId).message }
366
+ }
367
+ this.cdpEvents.push(event)
368
+ if (this.cdpEvents.length > MAX_CDP_EVENTS) {
369
+ this.cdpEvents.splice(0, this.cdpEvents.length - MAX_CDP_EVENTS)
370
+ }
371
+ return { accepted: true, event }
372
+ }
373
+
374
+ listNetworkEvents(args = {}) {
375
+ const limit = clampNumber(args.limit || 100, 1, 500, 100)
376
+ const sinceId = Number(args.sinceId || 0)
377
+ const method = String(args.method || "").trim().toUpperCase()
378
+ const urlContains = String(args.urlContains || "").trim().toLowerCase()
379
+ const browserSessionIds = args.allSessions
380
+ ? this.sessionIdsForUser(requireRouteUserId(args, "yunti_get_network_log"))
381
+ : new Set([this.resolveSessionId(args)])
382
+ let events = this.networkEvents
383
+ events = events.filter((event) => browserSessionIds.has(event.browserSessionId))
384
+ if (Number.isFinite(sinceId) && sinceId > 0) {
385
+ events = events.filter((event) => event.id > sinceId)
386
+ }
387
+ if (method) events = events.filter((event) => event.method === method)
388
+ if (urlContains) {
389
+ events = events.filter((event) => String(event.url || "").toLowerCase().includes(urlContains))
390
+ }
391
+ const total = events.length
392
+ events = events.slice(-limit)
393
+ return { events, total, returned: events.length }
394
+ }
395
+
396
+ clearNetworkEvents(args = {}) {
397
+ if (args.allSessions) {
398
+ const browserSessionIds = this.sessionIdsForUser(requireRouteUserId(args, "yunti_clear_network_log"))
399
+ const before = this.networkEvents.length
400
+ this.networkEvents = this.networkEvents.filter(
401
+ (event) => !browserSessionIds.has(event.browserSessionId)
402
+ )
403
+ return { deleted: before - this.networkEvents.length }
404
+ }
405
+ const browserSessionId = this.resolveSessionId(args)
406
+ const before = this.networkEvents.length
407
+ this.networkEvents = this.networkEvents.filter((event) => event.browserSessionId !== browserSessionId)
408
+ return { deleted: before - this.networkEvents.length }
409
+ }
410
+
411
+ getNetworkRequest(args = {}) {
412
+ const id = Number(args.eventId)
413
+ if (!Number.isFinite(id)) throw new Error("eventId is required")
414
+ const browserSessionId = this.resolveSessionId(args)
415
+ const event = this.networkEvents.find(e => e.id === id)
416
+ if (!event) throw new Error(`network request not found: ${id}`)
417
+ if (event.browserSessionId !== browserSessionId) {
418
+ throw new Error(`network request not found for browserSessionId: ${browserSessionId}`)
419
+ }
420
+ return event
421
+ }
422
+
423
+ listCdpEvents(args = {}) {
424
+ const limit = clampNumber(args.limit || 100, 1, 500, 100)
425
+ const sinceId = Number(args.sinceId || 0)
426
+ const method = String(args.method || "").trim()
427
+ const browserSessionIds = args.allSessions
428
+ ? this.sessionIdsForUser(requireRouteUserId(args, "yunti_get_cdp_events"))
429
+ : new Set([this.resolveSessionId(args)])
430
+ let events = this.cdpEvents
431
+ events = events.filter((event) => browserSessionIds.has(event.browserSessionId))
432
+ if (Number.isFinite(sinceId) && sinceId > 0) {
433
+ events = events.filter((event) => event.id > sinceId)
434
+ }
435
+ if (method) events = events.filter((event) => event.method === method)
436
+ const total = events.length
437
+ events = events.slice(-limit)
438
+ return { events, total, returned: events.length }
439
+ }
440
+
441
+ clearCdpEvents(args = {}) {
442
+ if (args.allSessions) {
443
+ const browserSessionIds = this.sessionIdsForUser(requireRouteUserId(args, "yunti_clear_cdp_events"))
444
+ const before = this.cdpEvents.length
445
+ this.cdpEvents = this.cdpEvents.filter(
446
+ (event) => !browserSessionIds.has(event.browserSessionId)
447
+ )
448
+ return { deleted: before - this.cdpEvents.length }
449
+ }
450
+ const browserSessionId = this.resolveSessionId(args)
451
+ const before = this.cdpEvents.length
452
+ this.cdpEvents = this.cdpEvents.filter((event) => event.browserSessionId !== browserSessionId)
453
+ return { deleted: before - this.cdpEvents.length }
454
+ }
455
+
456
+ recordConsoleEvent(input) {
457
+ this.cleanupExpiredSessions()
458
+ const event = {
459
+ id: this.nextConsoleMsgId++,
460
+ browserSessionId: truncateText(input.browserSessionId || "", 160),
461
+ tabId: Number.isFinite(Number(input.tabId)) ? Number(input.tabId) : null,
462
+ level: ["error", "warning", "info", "debug", "log", "verbose"].includes(input.level) ? input.level : "log",
463
+ text: truncateText(String(input.text || ""), 2000),
464
+ source: ["console-api", "javascript", "network", "other"].includes(input.source) ? input.source : "other",
465
+ url: input.url ? sanitizeUrl(input.url) : "",
466
+ lineNumber: Number.isFinite(Number(input.lineNumber)) ? Number(input.lineNumber) : null,
467
+ columnNumber: Number.isFinite(Number(input.columnNumber)) ? Number(input.columnNumber) : null,
468
+ stackTrace: input.stackTrace ? truncateText(String(input.stackTrace), 4000) : "",
469
+ args: Array.isArray(input.args) ? input.args.slice(0, 20).map(a => truncateText(String(a), 500)) : [],
470
+ timestamp: input.timestamp || new Date().toISOString(),
471
+ }
472
+ if (!event.browserSessionId) return { accepted: false, error: "browserSessionId is required" }
473
+ if (!this.getLiveSession(event.browserSessionId)) {
474
+ return { accepted: false, error: staleSessionError(event.browserSessionId).message }
475
+ }
476
+ this.consoleMessages.push(event)
477
+ if (this.consoleMessages.length > MAX_CONSOLE_MESSAGES) {
478
+ this.consoleMessages.splice(0, this.consoleMessages.length - MAX_CONSOLE_MESSAGES)
479
+ }
480
+ return { accepted: true, event }
481
+ }
482
+
483
+ listConsoleMessages(args = {}) {
484
+ const limit = clampNumber(args.limit || 100, 1, 500, 100)
485
+ const sinceId = Number(args.sinceId || 0)
486
+ const level = String(args.level || "").trim().toLowerCase()
487
+ const source = String(args.source || "").trim().toLowerCase()
488
+ const browserSessionIds = args.allSessions
489
+ ? this.sessionIdsForUser(requireRouteUserId(args, "yunti_list_console_messages"))
490
+ : new Set([this.resolveSessionId(args)])
491
+
492
+ let events = this.consoleMessages
493
+ events = events.filter(e => browserSessionIds.has(e.browserSessionId))
494
+ if (Number.isFinite(sinceId) && sinceId > 0) {
495
+ events = events.filter(e => e.id > sinceId)
496
+ }
497
+ if (level) events = events.filter(e => e.level === level)
498
+ if (source) events = events.filter(e => e.source === source)
499
+ const total = events.length
500
+ events = events.slice(-limit).reverse()
501
+ return { events, total, returned: events.length }
502
+ }
503
+
504
+ getConsoleMessage(args = {}) {
505
+ const id = Number(args.msgId)
506
+ if (!Number.isFinite(id)) throw new Error("msgId is required")
507
+ const browserSessionId = this.resolveSessionId(args)
508
+ const msg = this.consoleMessages.find(e => e.id === id)
509
+ if (!msg) throw new Error(`console message not found: ${id}`)
510
+ if (msg.browserSessionId !== browserSessionId) {
511
+ throw new Error(`console message not found for browserSessionId: ${browserSessionId}`)
512
+ }
513
+ return msg
514
+ }
515
+
516
+ clearConsoleMessages(args = {}) {
517
+ if (args.allSessions) {
518
+ const browserSessionIds = this.sessionIdsForUser(requireRouteUserId(args, "yunti_clear_console_messages"))
519
+ const before = this.consoleMessages.length
520
+ this.consoleMessages = this.consoleMessages.filter(
521
+ e => !browserSessionIds.has(e.browserSessionId)
522
+ )
523
+ return { deleted: before - this.consoleMessages.length }
524
+ }
525
+ const browserSessionId = this.resolveSessionId(args)
526
+ const before = this.consoleMessages.length
527
+ this.consoleMessages = this.consoleMessages.filter(e => e.browserSessionId !== browserSessionId)
528
+ return { deleted: before - this.consoleMessages.length }
529
+ }
530
+
531
+ async callBridgeLocalTool(tool, args = {}) {
532
+ switch (tool) {
533
+ case "yunti_select_page":
534
+ return this.selectPage(String(args.browserSessionId || ""), args)
535
+ case "yunti_get_network_log":
536
+ return this.listNetworkEvents(args)
537
+ case "yunti_list_network_requests":
538
+ return this.listNetworkEvents(args)
539
+ case "yunti_get_network_request":
540
+ return this.getNetworkRequest(args)
541
+ case "yunti_clear_network_log":
542
+ case "yunti_clear_network_requests":
543
+ return this.clearNetworkEvents(args)
544
+ case "yunti_get_cdp_events":
545
+ return this.listCdpEvents(args)
546
+ case "yunti_clear_cdp_events":
547
+ return this.clearCdpEvents(args)
548
+ case "yunti_list_console_messages":
549
+ return this.listConsoleMessages(args)
550
+ case "yunti_get_console_message":
551
+ return this.getConsoleMessage(args)
552
+ case "yunti_clear_console_messages":
553
+ return this.clearConsoleMessages(args)
554
+ case "yunti_remember_learning":
555
+ return rememberLearning(args)
556
+ case "yunti_get_learning_memory":
557
+ return getLearningMemory(args)
558
+ case "yunti_forget_learning_memory":
559
+ return forgetLearningMemory(args)
560
+ case "yunti_get_tool_usage_hints":
561
+ return toolUsageHints(args)
562
+ default:
563
+ throw new Error(`unknown bridge-local tool: ${tool}`)
564
+ }
565
+ }
566
+
567
+ async poll(browserSessionId, timeoutMs = 25_000) {
568
+ this.cleanupExpiredSessions()
569
+ const session = this.getLiveSession(browserSessionId)
570
+ if (!session) {
571
+ throw staleSessionError(browserSessionId, "not registered or heartbeat expired")
572
+ }
573
+ this.refreshSession(session)
574
+ if (session.queue.length > 0) return session.queue.shift()
575
+
576
+ return new Promise((resolve) => {
577
+ const timer = setTimeout(() => {
578
+ session.pollers = session.pollers.filter((poller) => poller !== finish)
579
+ resolve({ type: "noop", id: randomUUID() })
580
+ }, Math.max(1000, Math.min(timeoutMs, 30_000)))
581
+ const finish = (value) => {
582
+ clearTimeout(timer)
583
+ resolve(value)
584
+ }
585
+ session.pollers.push(finish)
586
+ })
587
+ }
588
+
589
+ submitResult({ browserSessionId, requestId, ok, result, error }) {
590
+ const pending = this.pendingRequests.get(requestId)
591
+ if (!pending) return { accepted: false }
592
+ if (browserSessionId && pending.browserSessionId !== browserSessionId) {
593
+ return { accepted: false, error: "browserSessionId mismatch" }
594
+ }
595
+ clearTimeout(pending.timer)
596
+ this.pendingRequests.delete(requestId)
597
+ if (ok) {
598
+ pending.resolve(result ?? null)
599
+ } else {
600
+ pending.reject(new Error(error || "Browser tool failed"))
601
+ }
602
+ return { accepted: true }
603
+ }
604
+ }