pi-lean-portal 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 (55) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +608 -0
  3. package/backends/chromium/index.ts +50 -0
  4. package/backends/chromium-py/bridge.py +67 -0
  5. package/backends/firefox/index.ts +60 -0
  6. package/backends/firefox-py/bridge.py +64 -0
  7. package/backends/playwright-base/playwright-plugin.ts +1294 -0
  8. package/backends/python-adapter.ts +1141 -0
  9. package/backends/python-base/pi_browser_bridge/__init__.py +71 -0
  10. package/backends/python-base/pi_browser_bridge/accessibility.py +408 -0
  11. package/backends/python-base/pi_browser_bridge/bot_detection.py +115 -0
  12. package/backends/python-base/pi_browser_bridge/bridge.py +598 -0
  13. package/backends/python-base/pi_browser_bridge/playwright_base.py +1222 -0
  14. package/backends/python-base/pi_browser_bridge/transport.py +167 -0
  15. package/backends/python-base/pyproject.toml +15 -0
  16. package/browser-cookies.ts +88 -0
  17. package/browser-profile.ts +260 -0
  18. package/browser-status.ts +84 -0
  19. package/browser-toggle.ts +527 -0
  20. package/core/fetch-backend.ts +466 -0
  21. package/core/guides.ts +467 -0
  22. package/core/plugin-api.ts +302 -0
  23. package/core/plugin-config.ts +388 -0
  24. package/core/plugin-registry.ts +263 -0
  25. package/core/router.ts +1186 -0
  26. package/core/shared/accessibility-tree.ts +408 -0
  27. package/core/shared/bot-detection.ts +187 -0
  28. package/core/shared/browser-events.ts +111 -0
  29. package/core/shared/dom-extractor.ts +550 -0
  30. package/core/shared/nav-settle.ts +187 -0
  31. package/core/shared/paths.ts +56 -0
  32. package/core/shared/session-manager.ts +258 -0
  33. package/core/shared/settings-reader.ts +63 -0
  34. package/core/shared/snapshot-cache.ts +231 -0
  35. package/core/shared/storage-state.ts +560 -0
  36. package/core/shared/task-id.ts +77 -0
  37. package/core/shared/url-safety.ts +164 -0
  38. package/index.ts +253 -0
  39. package/package.json +63 -0
  40. package/ship-manifest.test.ts +12 -0
  41. package/tools/browser-back.ts +50 -0
  42. package/tools/browser-click.ts +74 -0
  43. package/tools/browser-console.ts +160 -0
  44. package/tools/browser-inspect.ts +136 -0
  45. package/tools/browser-navigate.ts +254 -0
  46. package/tools/browser-press.ts +80 -0
  47. package/tools/browser-scroll.ts +56 -0
  48. package/tools/browser-snapshot.ts +90 -0
  49. package/tools/browser-type.ts +60 -0
  50. package/tools/index.ts +19 -0
  51. package/tools/utils.ts +157 -0
  52. package/tools/web-fetch.ts +147 -0
  53. package/tools/web-guide.ts +55 -0
  54. package/tools/web-learn.ts +128 -0
  55. package/verify-ship-manifest.ts +126 -0
@@ -0,0 +1,598 @@
1
+ """
2
+ BrowserBridge — base class for Python browser automation backends.
3
+
4
+ Provides JSON-RPC command routing, session lifecycle, element caching,
5
+ and a ``run()`` main loop. Subclasses override ``create_browser_session()``
6
+ and individual operation methods to implement specific browser backends
7
+ (e.g. Chromium via Playwright, Camoufox, etc.).
8
+
9
+ Protocol
10
+ --------
11
+ All communication is JSON-RPC 2.0 over stdin/stdout with newline-delimited
12
+ framing. See ``transport.py`` for details.
13
+
14
+ Lifecycle
15
+ ---------
16
+ 1. The TypeScript ``PythonPluginAdapter`` spawns the Python process.
17
+ 2. The bridge starts its ``run()`` loop, waiting for commands.
18
+ 3. First command is typically ``browser.navigate``, which calls
19
+ ``create_browser_session()`` (if no session exists yet for that taskId)
20
+ and then navigates.
21
+ 4. Subsequent commands use the existing session.
22
+ 5. ``browser.cleanup`` closes the session for a taskId.
23
+ 6. ``browser.shutdown`` (sent via cleanupAll) terminates the process.
24
+ """
25
+
26
+ import inspect
27
+ import sys
28
+ import traceback
29
+ from typing import Any, Optional
30
+
31
+ from .transport import (
32
+ read_request,
33
+ write_response,
34
+ make_success_response,
35
+ make_error_response,
36
+ make_parse_error,
37
+ make_invalid_request,
38
+ make_internal_error,
39
+ make_application_error,
40
+ InvalidRequestError,
41
+ APPLICATION_ERROR,
42
+ METHOD_NOT_FOUND,
43
+ INVALID_PARAMS,
44
+ SESSION_ERROR,
45
+ )
46
+ from .accessibility import parse_snapshot, AriaParseResult
47
+
48
+ # ─── Default timeout ──────────────────────────────────────────────────
49
+
50
+ DEFAULT_NAVIGATION_TIMEOUT_MS: int = 30_000
51
+ DEFAULT_INTERACTION_TIMEOUT_MS: int = 10_000
52
+
53
+
54
+ class BrowserBridge:
55
+ """Base class for Python browser automation bridges.
56
+
57
+ Subclass this and override:
58
+
59
+ * ``create_browser_session(task_id, config)`` — mandatory
60
+ * ``create_browser_context(config)`` — mandatory (creates isolated context per task)
61
+ * Any operation methods you want to customise (optional)
62
+ * ``close_browser_session(task_id)`` — optional (default unregisters)
63
+
64
+ Call ``run()`` to start the JSON-RPC command loop.
65
+
66
+ Named profiles are fully handled by the TypeScript side via
67
+ ``core/shared/storage-state.ts`` (disk persistence). The Python
68
+ bridge receives ``storageState`` in the navigate request and applies
69
+ it when creating a new BrowserContext — it does NOT track shared
70
+ contexts across tasks.
71
+ """
72
+
73
+ # ── Session storage ─────────────────────────────────────────
74
+
75
+ #: Per-taskId session data: {task_id: {...}}.
76
+ #: The dict contents are backend-specific (page, context, etc.).
77
+ #: You can store whatever you need here.
78
+ sessions: dict[str, dict[str, Any]]
79
+
80
+ #: Per-taskId element cache: {task_id: AriaParseResult}.
81
+ element_caches: dict[str, AriaParseResult]
82
+
83
+ #: Whether the bridge is still running.
84
+ _running: bool
85
+
86
+ def __init__(self) -> None:
87
+ self.sessions = {}
88
+ self.element_caches = {}
89
+ self._running = False
90
+
91
+ # ── Subclass hooks ──────────────────────────────────────────
92
+
93
+ def create_browser_session(self, task_id: str, config: dict[str, Any]) -> dict[str, Any]:
94
+ """Create a new browser session for the given task.
95
+
96
+ Must return a dict that will be stored in ``self.sessions[task_id]``.
97
+ The dict is backend-specific (e.g. containing a Playwright page/context).
98
+
99
+ Raises:
100
+ RuntimeError: if a session cannot be created.
101
+ """
102
+ raise NotImplementedError(
103
+ f"{type(self).__name__} must implement create_browser_session()"
104
+ )
105
+
106
+ def close_browser_session(self, task_id: str) -> None:
107
+ """Close and clean up the session for the given task.
108
+
109
+ Default implementation removes the session from the dict. Override
110
+ to close browser pages/contexts before removal.
111
+ """
112
+ self.sessions.pop(task_id, None)
113
+ self.element_caches.pop(task_id, None)
114
+
115
+ def create_browser_context(self, config: dict[str, Any]) -> Any:
116
+ """Create a new isolated BrowserContext for a task session.
117
+
118
+ Each task gets its own BrowserContext with no sharing between
119
+ tasks. Named profiles are handled by the TypeScript side via
120
+ ``core/shared/storage-state.ts`` — the ``config`` may contain
121
+ ``storageState`` to restore cookies and localStorage.
122
+
123
+ Args:
124
+ config: Configuration dict (may contain ``storageState``,
125
+ ``viewport``, ``userAgent``, etc.)
126
+
127
+ Returns:
128
+ A backend-specific BrowserContext object.
129
+
130
+ Raises:
131
+ NotImplementedError: if the subclass doesn't implement this.
132
+ """
133
+ raise NotImplementedError(
134
+ f"{type(self).__name__} must implement create_browser_context()"
135
+ )
136
+
137
+ # ── Session helpers ─────────────────────────────────────────
138
+
139
+ def get_session(self, task_id: str) -> Optional[dict[str, Any]]:
140
+ """Get the session data for a task, or None."""
141
+ return self.sessions.get(task_id)
142
+
143
+ def require_session(self, task_id: str) -> dict[str, Any]:
144
+ """Get the session for a task, raising SESSION_ERROR if absent."""
145
+ session = self.get_session(task_id)
146
+ if session is None:
147
+ raise SessionNotFoundError(
148
+ f"No active session for task '{task_id}'. "
149
+ "Call browser.navigate first."
150
+ )
151
+ return session
152
+
153
+ def ensure_session(self, task_id: str, config: Optional[dict[str, Any]] = None) -> dict[str, Any]:
154
+ """Get or create a session for the given task."""
155
+ session = self.get_session(task_id)
156
+ if session is not None:
157
+ return session
158
+ new_session = self.create_browser_session(task_id, config or {})
159
+ self.sessions[task_id] = new_session
160
+ return new_session
161
+
162
+ # ── Page session setup hook ──────────────────────────────────
163
+
164
+ def _setup_page_session(self, page: Any) -> dict[str, Any]:
165
+ """Set up event handlers (console capture, dialog dismissal) on a new page.
166
+
167
+ Base implementation returns a minimal session dict. Subclasses
168
+ (e.g. ChromiumPyBridge) override this to attach console-message
169
+ accumulators and dialog handlers.
170
+ """
171
+ return {"page": page}
172
+
173
+ def get_element_cache(self, task_id: str) -> Optional[AriaParseResult]:
174
+ """Get the cached element parse result for a task, or None."""
175
+ return self.element_caches.get(task_id)
176
+
177
+ def set_element_cache(self, task_id: str, result: AriaParseResult) -> None:
178
+ """Store a parsed element cache for a task."""
179
+ self.element_caches[task_id] = result
180
+
181
+ # ── Operation stubs (override in subclasses) ────────────────
182
+
183
+ def do_navigate(
184
+ self,
185
+ task_id: str,
186
+ url: str,
187
+ timeout_ms: int = DEFAULT_NAVIGATION_TIMEOUT_MS,
188
+ storageState: Optional[dict[str, Any]] = None,
189
+ profileName: Optional[str] = None,
190
+ profileMode: Optional[str] = None,
191
+ ) -> dict[str, Any]:
192
+ """Navigate the browser to a URL.
193
+
194
+ Subclasses interpret the extra params:
195
+
196
+ - ``storageState``: Playwright storage state for session restoration.
197
+ - ``profileName``: Named profile name (e.g. "work", "shopping").
198
+ - ``profileMode``: ``"none"`` / ``"session"`` / ``"named"``.
199
+ All modes create task-isolated BrowserContexts; named profiles
200
+ are handled by the TypeScript side via ``storageState``
201
+ (``core/shared/storage-state.ts``, disk persistence).
202
+
203
+ Must return a dict with keys:
204
+ success (bool), url (str), title (str),
205
+ snapshot (str), elementCount (int)
206
+ Optionally: botDetected (bool), profileName (str)
207
+ """
208
+ raise NotImplementedError(
209
+ f"{type(self).__name__} must implement do_navigate()"
210
+ )
211
+
212
+ def do_snapshot(self, task_id: str) -> dict[str, Any]:
213
+ """Take an accessibility snapshot of the current page.
214
+
215
+ Must return a dict with keys:
216
+ success (bool), snapshot (str), elementCount (int)
217
+ """
218
+ raise NotImplementedError(
219
+ f"{type(self).__name__} must implement do_snapshot()"
220
+ )
221
+
222
+ def do_click(self, task_id: str, ref: str) -> dict[str, Any]:
223
+ """Click an element by @e ref.
224
+
225
+ Must return a dict with keys:
226
+ success (bool)
227
+ Optionally: snapshot (str), elementCount (int), newUrl (str), newTitle (str)
228
+ """
229
+ raise NotImplementedError(
230
+ f"{type(self).__name__} must implement do_click()"
231
+ )
232
+
233
+ def do_type(self, task_id: str, ref: str, text: str) -> dict[str, Any]:
234
+ """Type text into an element by @e ref.
235
+
236
+ Must return a dict with keys:
237
+ success (bool)
238
+ Optionally: snapshot (str), elementCount (int), newUrl (str), newTitle (str)
239
+ """
240
+ raise NotImplementedError(
241
+ f"{type(self).__name__} must implement do_type()"
242
+ )
243
+
244
+ def do_scroll(self, task_id: str, direction: str) -> dict[str, Any]:
245
+ """Scroll the page up or down.
246
+
247
+ Must return a dict with keys:
248
+ success (bool)
249
+ Optionally: snapshot (str), elementCount (int), newUrl (str), newTitle (str)
250
+ """
251
+ raise NotImplementedError(
252
+ f"{type(self).__name__} must implement do_scroll()"
253
+ )
254
+
255
+ def do_go_back(self, task_id: str) -> dict[str, Any]:
256
+ """Navigate back in history.
257
+
258
+ Must return a dict with keys:
259
+ success (bool)
260
+ Optionally: snapshot (str), elementCount (int), newUrl (str), newTitle (str)
261
+ """
262
+ raise NotImplementedError(
263
+ f"{type(self).__name__} must implement do_go_back()"
264
+ )
265
+
266
+ def do_press(self, task_id: str, key: str) -> dict[str, Any]:
267
+ """Press a keyboard key on the current page (or focused element).
268
+
269
+ Must return a dict with keys:
270
+ success (bool)
271
+ Optionally: snapshot (str), elementCount (int), newUrl (str), newTitle (str)
272
+ """
273
+ raise NotImplementedError(
274
+ f"{type(self).__name__} must implement do_press()"
275
+ )
276
+
277
+ def do_screenshot(
278
+ self,
279
+ task_id: str,
280
+ full_page: bool = False,
281
+ ) -> dict[str, Any]:
282
+ """Take a screenshot of the current page.
283
+
284
+ Must return a dict with keys:
285
+ success (bool), dataUri (str) — JPEG base64 data URI
286
+ """
287
+ raise NotImplementedError(
288
+ f"{type(self).__name__} must implement do_screenshot()"
289
+ )
290
+
291
+ def do_get_console_messages(self, task_id: str) -> dict[str, Any]:
292
+ """Get captured console messages.
293
+
294
+ Must return a dict with keys:
295
+ success (bool), messages (list) — each with type, text
296
+ """
297
+ raise NotImplementedError(
298
+ f"{type(self).__name__} must implement do_get_console_messages()"
299
+ )
300
+
301
+ def do_clear_console(self, task_id: str) -> dict[str, Any]:
302
+ """Clear captured console messages.
303
+
304
+ Must return a dict with keys:
305
+ success (bool)
306
+ """
307
+ raise NotImplementedError(
308
+ f"{type(self).__name__} must implement do_clear_console()"
309
+ )
310
+
311
+ def do_evaluate(self, task_id: str, expression: str) -> dict[str, Any]:
312
+ """Evaluate JavaScript in the page.
313
+
314
+ Must return a dict with keys:
315
+ success (bool)
316
+ Optionally: result (any)
317
+ """
318
+ raise NotImplementedError(
319
+ f"{type(self).__name__} must implement do_evaluate()"
320
+ )
321
+
322
+ def do_get_cookies(
323
+ self, task_id: str, urls: Optional[list[str]] = None
324
+ ) -> dict[str, Any]:
325
+ """Get all cookies, optionally filtered by URL.
326
+
327
+ Must return a dict with keys:
328
+ success (bool), cookies (list) — each with name, value, domain, ...
329
+ """
330
+ raise NotImplementedError(
331
+ f"{type(self).__name__} must implement do_get_cookies()"
332
+ )
333
+
334
+ def do_add_cookies(
335
+ self, task_id: str, cookies: list[dict[str, Any]]
336
+ ) -> dict[str, Any]:
337
+ """Add cookies to the browser context.
338
+
339
+ Must return a dict with keys:
340
+ success (bool)
341
+ """
342
+ raise NotImplementedError(
343
+ f"{type(self).__name__} must implement do_add_cookies()"
344
+ )
345
+
346
+ def do_clear_cookies(
347
+ self,
348
+ task_id: str,
349
+ name: Optional[str] = None,
350
+ domain: Optional[str] = None,
351
+ path: Optional[str] = None,
352
+ ) -> dict[str, Any]:
353
+ """Clear cookies, optionally filtered by name/domain/path.
354
+
355
+ Must return a dict with keys:
356
+ success (bool)
357
+ """
358
+ raise NotImplementedError(
359
+ f"{type(self).__name__} must implement do_clear_cookies()"
360
+ )
361
+
362
+ def do_get_storage_state(self, task_id: str) -> dict[str, Any]:
363
+ """Get full storage state (cookies + localStorage + IndexedDB).
364
+
365
+ Must return a dict with keys:
366
+ success (bool), cookies (list), origins (list)
367
+ """
368
+ raise NotImplementedError(
369
+ f"{type(self).__name__} must implement do_get_storage_state()"
370
+ )
371
+
372
+ def do_cleanup(self, task_id: str) -> dict[str, Any]:
373
+ """Clean up resources for a specific task.
374
+
375
+ Profile persistence is handled by the TypeScript side
376
+ (``python-adapter.ts`` auto-saves storage state before calling
377
+ cleanup), so this method always calls ``close_browser_session()``.
378
+
379
+ Must return a dict with keys:
380
+ success (bool)
381
+ """
382
+ self.close_browser_session(task_id)
383
+ return {"success": True}
384
+
385
+ # ── Command routing ─────────────────────────────────────────
386
+
387
+ def handle_command(self, method: str, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
388
+ """Route a JSON-RPC method to the appropriate operation handler.
389
+
390
+ Returns a JSON-RPC response dict (either result or error).
391
+ """
392
+ try:
393
+ if method == "ping":
394
+ return make_success_response(cmd_id, "pong")
395
+
396
+ if method == "shutdown":
397
+ self._running = False
398
+ return make_success_response(cmd_id, "shutting_down")
399
+
400
+ if method == "browser.navigate":
401
+ url = self._require_param(params, "url", str, cmd_id)
402
+ task_id = self._require_param(params, "taskId", str, cmd_id)
403
+ timeout_ms = params.get("timeoutMs", DEFAULT_NAVIGATION_TIMEOUT_MS)
404
+ storage_state = params.get("storageState")
405
+ profile_name = params.get("profileName")
406
+ profile_mode = params.get("profileMode")
407
+ result = self.do_navigate(
408
+ task_id, url, timeout_ms,
409
+ storageState=storage_state,
410
+ profileName=profile_name,
411
+ profileMode=profile_mode,
412
+ )
413
+ return make_success_response(cmd_id, result)
414
+
415
+ if method == "browser.snapshot":
416
+ task_id = self._require_param(params, "taskId", str, cmd_id)
417
+ result = self.do_snapshot(task_id)
418
+ return make_success_response(cmd_id, result)
419
+
420
+ if method == "browser.click":
421
+ task_id = self._require_param(params, "taskId", str, cmd_id)
422
+ ref = self._require_param(params, "ref", str, cmd_id)
423
+ result = self.do_click(task_id, ref)
424
+ return make_success_response(cmd_id, result)
425
+
426
+ if method == "browser.type":
427
+ task_id = self._require_param(params, "taskId", str, cmd_id)
428
+ ref = self._require_param(params, "ref", str, cmd_id)
429
+ text = self._require_param(params, "text", str, cmd_id)
430
+ result = self.do_type(task_id, ref, text)
431
+ return make_success_response(cmd_id, result)
432
+
433
+ if method == "browser.scroll":
434
+ task_id = self._require_param(params, "taskId", str, cmd_id)
435
+ direction = self._require_param(params, "direction", str, cmd_id)
436
+ if direction not in ("up", "down"):
437
+ return make_error_response(
438
+ cmd_id, INVALID_PARAMS,
439
+ 'direction must be "up" or "down"',
440
+ )
441
+ result = self.do_scroll(task_id, direction)
442
+ return make_success_response(cmd_id, result)
443
+
444
+ if method == "browser.goBack":
445
+ task_id = self._require_param(params, "taskId", str, cmd_id)
446
+ result = self.do_go_back(task_id)
447
+ return make_success_response(cmd_id, result)
448
+
449
+ if method == "browser.press":
450
+ task_id = self._require_param(params, "taskId", str, cmd_id)
451
+ key = self._require_param(params, "key", str, cmd_id)
452
+ result = self.do_press(task_id, key)
453
+ return make_success_response(cmd_id, result)
454
+
455
+ if method == "browser.screenshot":
456
+ task_id = self._require_param(params, "taskId", str, cmd_id)
457
+ full_page = params.get("fullPage", False)
458
+ result = self.do_screenshot(task_id, full_page)
459
+ return make_success_response(cmd_id, result)
460
+
461
+ if method == "browser.getConsoleMessages":
462
+ task_id = self._require_param(params, "taskId", str, cmd_id)
463
+ result = self.do_get_console_messages(task_id)
464
+ return make_success_response(cmd_id, result)
465
+
466
+ if method == "browser.clearConsole":
467
+ task_id = self._require_param(params, "taskId", str, cmd_id)
468
+ result = self.do_clear_console(task_id)
469
+ return make_success_response(cmd_id, result)
470
+
471
+ if method == "browser.evaluate":
472
+ task_id = self._require_param(params, "taskId", str, cmd_id)
473
+ expression = self._require_param(params, "expression", str, cmd_id)
474
+ result = self.do_evaluate(task_id, expression)
475
+ return make_success_response(cmd_id, result)
476
+
477
+ if method == "browser.getCookies":
478
+ task_id = self._require_param(params, "taskId", str, cmd_id)
479
+ urls = params.get("urls")
480
+ result = self.do_get_cookies(task_id, urls)
481
+ return make_success_response(cmd_id, result)
482
+
483
+ if method == "browser.addCookies":
484
+ task_id = self._require_param(params, "taskId", str, cmd_id)
485
+ cookies = self._require_param(params, "cookies", list, cmd_id)
486
+ result = self.do_add_cookies(task_id, cookies)
487
+ return make_success_response(cmd_id, result)
488
+
489
+ if method == "browser.clearCookies":
490
+ task_id = self._require_param(params, "taskId", str, cmd_id)
491
+ name = params.get("name")
492
+ domain = params.get("domain")
493
+ path = params.get("path")
494
+ # No required params beyond taskId — empty call clears ALL cookies
495
+ result = self.do_clear_cookies(task_id, name, domain, path)
496
+ return make_success_response(cmd_id, result)
497
+
498
+ if method == "browser.getStorageState":
499
+ task_id = self._require_param(params, "taskId", str, cmd_id)
500
+ result = self.do_get_storage_state(task_id)
501
+ return make_success_response(cmd_id, result)
502
+
503
+ if method == "browser.cleanup":
504
+ task_id = self._require_param(params, "taskId", str, cmd_id)
505
+ result = self.do_cleanup(task_id)
506
+ return make_success_response(cmd_id, result)
507
+
508
+ # Unknown method
509
+ return make_error_response(
510
+ cmd_id,
511
+ METHOD_NOT_FOUND,
512
+ f"Method not found: {method}",
513
+ )
514
+
515
+ except SessionNotFoundError as exc:
516
+ return make_error_response(cmd_id, SESSION_ERROR, str(exc))
517
+ except NotImplementedError as exc:
518
+ return make_application_error(cmd_id, str(exc))
519
+ except Exception as exc:
520
+ tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
521
+ return make_application_error(cmd_id, str(exc), traceback_str=tb)
522
+
523
+ # ── Main loop ─────────────────────────────────────────────
524
+
525
+ def run(self) -> None:
526
+ """Start the main JSON-RPC command loop.
527
+
528
+ Reads requests from stdin, dispatches them, and writes responses
529
+ to stdout. Runs until EOF or a ``shutdown`` command.
530
+ """
531
+ self._running = True
532
+ while self._running:
533
+ try:
534
+ request = read_request()
535
+ if request is None:
536
+ break # EOF
537
+
538
+ cmd_id = request.get("id")
539
+ method = request.get("method", "")
540
+ params = request.get("params", {})
541
+
542
+ if not isinstance(params, dict):
543
+ write_response(make_error_response(
544
+ cmd_id, INVALID_PARAMS,
545
+ '"params" must be a JSON object',
546
+ ))
547
+ continue
548
+
549
+ response = self.handle_command(method, params, cmd_id)
550
+ write_response(response)
551
+
552
+ except InvalidRequestError:
553
+ # Valid JSON but not a valid JSON-RPC Request object
554
+ write_response(make_invalid_request(None))
555
+ except ValueError:
556
+ # JSON parse error
557
+ write_response(make_parse_error(None))
558
+ except EOFError:
559
+ break
560
+ except KeyboardInterrupt:
561
+ break
562
+ except Exception as exc:
563
+ tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
564
+ write_response(make_application_error(None, str(exc), traceback_str=tb))
565
+
566
+ # ── Internal helpers ──────────────────────────────────────
567
+
568
+ @staticmethod
569
+ def _require_param(
570
+ params: dict[str, Any],
571
+ key: str,
572
+ expected_type: type,
573
+ cmd_id: Any,
574
+ ) -> Any:
575
+ """Require a param to exist and be of the expected type.
576
+
577
+ Raises a JSON-RPC invalid params error if the param is missing
578
+ or has the wrong type.
579
+ """
580
+ if key not in params:
581
+ raise InvalidParamsError(f'Missing required parameter: "{key}"')
582
+ value = params[key]
583
+ if not isinstance(value, expected_type):
584
+ raise InvalidParamsError(
585
+ f'Parameter "{key}" must be of type {expected_type.__name__}, '
586
+ f"got {type(value).__name__}"
587
+ )
588
+ return value
589
+
590
+
591
+ # ─── Custom exceptions ────────────────────────────────────────────────
592
+
593
+ class SessionNotFoundError(Exception):
594
+ """Raised when an operation requires a session but none exists."""
595
+
596
+
597
+ class InvalidParamsError(Exception):
598
+ """Raised when a required parameter is missing or has the wrong type."""