@code-yeongyu/senpi-codemode 2026.10.7 → 2026.10.9

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 (56) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +67 -5
  3. package/package.json +5 -4
  4. package/src/bridge/http-server.ts +9 -1
  5. package/src/bridge/kernel-tools-protocol.ts +8 -0
  6. package/src/bridge/protocol.ts +4 -0
  7. package/src/bridges/eval-virtual-schemas.ts +22 -0
  8. package/src/environments/editable-check.ts +116 -0
  9. package/src/environments/install-lock.ts +223 -0
  10. package/src/environments/py-environment.ts +98 -0
  11. package/src/environments/py-installer.ts +247 -0
  12. package/src/environments/python-environments.ts +85 -0
  13. package/src/environments/revision-store.ts +110 -0
  14. package/src/extension/bridge-tool-call.ts +2 -0
  15. package/src/extension/cell-kernel-tools.ts +28 -0
  16. package/src/extension/kernel-registration.ts +5 -0
  17. package/src/extension/kernel-replacement.ts +20 -0
  18. package/src/extension/runtime-factory.ts +12 -0
  19. package/src/extension/session-manager-proxy.ts +6 -0
  20. package/src/extension/session-manager.ts +24 -2
  21. package/src/index.ts +1 -0
  22. package/src/kernels/jl/prelude.jl +6 -1
  23. package/src/kernels/js/context-manager.ts +15 -1
  24. package/src/kernels/js/kernel-tools-errors.js +2 -0
  25. package/src/kernels/js/kernel-tools-errors.ts +2 -0
  26. package/src/kernels/js/local-module-loader.ts +9 -4
  27. package/src/kernels/js/worker-runtime.js +3 -1
  28. package/src/kernels/js/workpool.js +5 -2
  29. package/src/kernels/py/kernel-contract.ts +11 -2
  30. package/src/kernels/py/kernel-tools-host.ts +97 -0
  31. package/src/kernels/py/kernel.ts +48 -10
  32. package/src/kernels/py/prelude.py +771 -18
  33. package/src/kernels/py/process.ts +15 -4
  34. package/src/kernels/py/startup.ts +58 -20
  35. package/src/kernels/py/transport.ts +49 -8
  36. package/src/kernels/rb/workpool.rb +7 -1
  37. package/src/kernels/sandbox/vendor/pi-codemode/LICENSE +21 -0
  38. package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +42 -0
  39. package/src/kernels/sandbox/vendor/pi-codemode/identifier.ts +14 -0
  40. package/src/kernels/sandbox/vendor/pi-codemode/runtime/host.ts +514 -0
  41. package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +431 -0
  42. package/src/kernels/sandbox/vendor/pi-codemode/runtime/protocol.ts +61 -0
  43. package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +231 -0
  44. package/src/kernels/sandbox/vendor/pi-codemode/types.ts +185 -0
  45. package/src/kernels/sandbox/vendor/pi-codemode/wasm.ts +37 -0
  46. package/src/kernels/shared/cell-source-at-start.ts +18 -0
  47. package/src/kernels/shared/kernel-tools-pump.ts +6 -0
  48. package/src/kernels/shared/process-cpu.ts +69 -0
  49. package/src/kernels/shared/subprocess-queue.ts +1 -0
  50. package/src/tool/cell-handler.ts +1 -1
  51. package/src/tool/eval-tool-options.ts +3 -0
  52. package/src/tool/load-cell.ts +73 -0
  53. package/src/tool/magic-cell-host.ts +65 -0
  54. package/src/tool/magic-cells.ts +65 -0
  55. package/src/tool/run-eval-cell.ts +30 -0
  56. package/src/tool/types.ts +27 -0
@@ -13,6 +13,7 @@ import asyncio # noqa: ANYIO_OK — stdlib-only embedded kernel runner.
13
13
  import base64
14
14
  import codecs
15
15
  import contextlib
16
+ import importlib
16
17
  import gc
17
18
  import inspect
18
19
  import io
@@ -26,12 +27,18 @@ import subprocess
26
27
  import time
27
28
  import traceback
28
29
  import types
30
+ import typing
29
31
  import urllib.error
30
32
  import urllib.request
31
33
  import uuid
32
34
  from collections.abc import Iterable
33
35
  from concurrent.futures import ThreadPoolExecutor, as_completed
34
36
  from pathlib import Path
37
+ import contextvars
38
+ import queue
39
+ import threading
40
+ from dataclasses import dataclass, field
41
+ from typing import Iterator
35
42
  from threading import Lock, Thread
36
43
  from typing import Any, Callable, Union
37
44
  from urllib.parse import unquote
@@ -368,7 +375,15 @@ _BRIDGE_SOCKET_TIMEOUT_SECONDS = 60
368
375
  _WAIT_SOCKET_GRACE_SECONDS = 30
369
376
 
370
377
 
378
+ # The host's secret for the run in this context. Every host call carries it, so the host gives the call that run's
379
+ # kernel tools; copied contexts (parallel workers) keep it, plain threads do not, and the host forgets it at settle.
380
+ CURRENT_CELL_TOKEN: contextvars.ContextVar[str | None] = contextvars.ContextVar("senpi_cell_token", default=None)
381
+
382
+
371
383
  def bridge_post(path: str, payload: dict[str, Any], *, socket_timeout: float | None = _BRIDGE_SOCKET_TIMEOUT_SECONDS) -> Any:
384
+ cell_token = CURRENT_CELL_TOKEN.get()
385
+ if path == "/call" and cell_token is not None and "cellToken" not in payload:
386
+ payload = {**payload, "cellToken": cell_token}
372
387
  port = CONNECTION.get("port")
373
388
  token = CONNECTION.get("token")
374
389
  if not isinstance(port, int) or not isinstance(token, str):
@@ -380,15 +395,20 @@ def bridge_post(path: str, payload: dict[str, Any], *, socket_timeout: float | N
380
395
  headers={"authorization": f"Bearer {token}", "content-type": "application/json"},
381
396
  method="POST",
382
397
  )
398
+ _guard_host_call(payload)
383
399
  emit_status(TIMEOUT_PAUSE_OP, force=True)
384
400
  try:
385
- try:
386
- with _BRIDGE_OPENER.open(request, timeout=socket_timeout) as response:
387
- response_data = response.read()
388
- except urllib.error.HTTPError as exc:
389
- response_data = exc.read()
401
+ with KERNEL_TOOL_TOKEN.parked():
402
+ try:
403
+ with _BRIDGE_OPENER.open(request, timeout=socket_timeout) as response:
404
+ response_data = response.read()
405
+ except urllib.error.HTTPError as exc:
406
+ response_data = exc.read()
390
407
  finally:
391
408
  emit_status(TIMEOUT_RESUME_OP, force=True)
409
+ invocation = CURRENT.get()
410
+ if invocation is not None and invocation.cancelled.is_set():
411
+ raise Cancelled(CANCELLED_AT_PARK_POINT)
392
412
 
393
413
  try:
394
414
  body = json.loads(response_data.decode("utf-8"))
@@ -402,6 +422,591 @@ def bridge_post(path: str, payload: dict[str, Any], *, socket_timeout: float | N
402
422
  raise PreludeRuntimeError(str(error))
403
423
 
404
424
 
425
+ # --- Kernel tools (`@tool`): inference, admission token, output routing, registry and serving threads. ---
426
+ # Admission is one interpreter-owner token: the main thread holds it while a cell runs and releases it only inside a
427
+ # host bridge call or while idle; a callback runs only while holding it, so a cell busy in pure computation never yields.
428
+
429
+ MCP_TOOL_NAME_MAX_LENGTH = 64
430
+ _NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
431
+ RESERVED_TOOL_NAMES = frozenset({"__agent__", "__output__", "__schema__", "defined", "undefine"})
432
+ _NONE_TYPE = type(None)
433
+
434
+
435
+ class KernelToolError(Exception):
436
+ def __init__(self, code: str, message: str, details: dict[str, Any] | None = None) -> None:
437
+ super().__init__(message)
438
+ self.code = code
439
+ self.details = details
440
+
441
+
442
+ def tool_key(name: str) -> str:
443
+ return re.sub(r"[^a-zA-Z0-9_-]", "_", name).replace("-", "_")
444
+
445
+
446
+ def validate_tool_name(name: Any) -> str:
447
+ if not isinstance(name, str) or not name:
448
+ raise KernelToolError("invalid_tool_definition", "@tool name must be a non-empty string")
449
+ if not _NAME_RE.match(name) or len(name) > MCP_TOOL_NAME_MAX_LENGTH:
450
+ raise KernelToolError("invalid_tool_definition", f"Kernel tool name must match MCP name grammar: {name}")
451
+ if tool_key(name) in {tool_key(reserved) for reserved in RESERVED_TOOL_NAMES}:
452
+ raise KernelToolError("reserved_tool_name", f"Kernel tool name is reserved: {name}")
453
+ return name
454
+
455
+
456
+ def _resolved_hints(fn: Callable[..., Any], params: list[inspect.Parameter]) -> dict[str, Any]:
457
+ # Only parameters shape the input schema; the return annotation may name a class defined later.
458
+ def parameters_only() -> None: ...
459
+
460
+ parameters_only.__annotations__ = {
461
+ param.name: param.annotation for param in params if param.annotation is not inspect.Parameter.empty
462
+ }
463
+ try:
464
+ return typing.get_type_hints(parameters_only, globalns=getattr(fn, "__globals__", None), include_extras=True)
465
+ except Exception as exc: # noqa: BROAD_EXCEPT_OK — any resolution failure is reported with the annotation that caused it.
466
+ globalns = getattr(fn, "__globals__", {})
467
+ for param in params:
468
+ annotation = getattr(param.annotation, "__forward_arg__", param.annotation)
469
+ if not isinstance(annotation, str):
470
+ continue
471
+ try:
472
+ eval(annotation, globalns) # noqa: S307 — the same evaluation get_type_hints performs, one annotation at a time.
473
+ except Exception as inner: # noqa: BROAD_EXCEPT_OK — names the annotation that failed.
474
+ raise KernelToolError(
475
+ "invalid_tool_definition",
476
+ f"can't resolve the annotation {annotation!r} on parameter {param.name!r} ({inner}); pass schema=",
477
+ ) from inner
478
+ raise KernelToolError("invalid_tool_definition", f"can't resolve the type hints ({exc}); pass schema=") from exc
479
+
480
+
481
+ def _schema_for(annotation: Any, where: str) -> dict[str, Any]:
482
+ if annotation is inspect.Parameter.empty or annotation is typing.Any:
483
+ return {}
484
+ origin = typing.get_origin(annotation)
485
+ args = typing.get_args(annotation)
486
+ if origin is typing.Annotated:
487
+ schema = _schema_for(args[0], where)
488
+ description = next((item for item in args[1:] if isinstance(item, str)), None)
489
+ return {**schema, "description": description} if description is not None else schema
490
+ if annotation is _NONE_TYPE or annotation is None:
491
+ return {"type": "null"}
492
+ if annotation is bool:
493
+ return {"type": "boolean"}
494
+ if annotation is int:
495
+ return {"type": "integer"}
496
+ if annotation is float:
497
+ return {"type": "number"}
498
+ if annotation is str:
499
+ return {"type": "string"}
500
+ if annotation is list or annotation is typing.List:
501
+ return {"type": "array"}
502
+ if annotation is dict or annotation is typing.Dict:
503
+ return {"type": "object"}
504
+ if origin is list:
505
+ return {"type": "array", "items": _schema_for(args[0], where)} if args else {"type": "array"}
506
+ if origin is dict:
507
+ if args and args[0] is not str:
508
+ raise KernelToolError("invalid_tool_definition", f"{where}: dict keys must be str")
509
+ return {"type": "object", "additionalProperties": _schema_for(args[1], where)} if args else {"type": "object"}
510
+ if origin is typing.Literal:
511
+ if not all(isinstance(value, (str, int, float, bool)) or value is None for value in args):
512
+ raise KernelToolError("invalid_tool_definition", f"{where}: Literal values must be JSON scalars")
513
+ return {"enum": list(args)}
514
+ if origin is typing.Union or origin is getattr(types, "UnionType", None):
515
+ return {"anyOf": [_schema_for(item, where) for item in args]}
516
+ raise KernelToolError("invalid_tool_definition", f"{where}: unsupported annotation {annotation!r}; pass schema=")
517
+
518
+
519
+ def _json_default(value: Any, where: str) -> Any:
520
+ if callable(value):
521
+ raise KernelToolError("invalid_tool_definition", f"{where}: default must be a JSON value, not a callable")
522
+ try:
523
+ json.dumps(value, allow_nan=False)
524
+ except (TypeError, ValueError) as exc:
525
+ raise KernelToolError("invalid_tool_definition", f"{where}: default must be a JSON value ({exc})") from exc
526
+ return value
527
+
528
+
529
+ def _description(fn: Callable[..., Any], explicit: Any) -> str:
530
+ if explicit is not None:
531
+ if not isinstance(explicit, str):
532
+ raise KernelToolError("invalid_tool_definition", "@tool description must be a string")
533
+ return explicit
534
+ doc = inspect.getdoc(fn) or ""
535
+ return doc.split("\n\n", 1)[0].strip()
536
+
537
+
538
+ def _signature(fn: Callable[..., Any]) -> inspect.Signature:
539
+ # Python 3.14 evaluates annotations lazily; asking for forward references keeps a return type that names
540
+ # a class defined later from failing here.
541
+ try:
542
+ import annotationlib
543
+
544
+ return inspect.signature(fn, annotation_format=annotationlib.Format.FORWARDREF)
545
+ except ImportError:
546
+ return inspect.signature(fn)
547
+
548
+
549
+ def infer_tool(fn: Any, *, name: Any = None, description: Any = None, schema: Any = None) -> dict[str, Any]:
550
+ if not callable(fn) or not hasattr(fn, "__name__"):
551
+ raise KernelToolError("invalid_tool_definition", "@tool requires a named function")
552
+ tool_name = validate_tool_name(fn.__name__ if name is None else name)
553
+ params = list(_signature(fn).parameters.values())
554
+ for param in params:
555
+ if param.kind is inspect.Parameter.POSITIONAL_ONLY:
556
+ raise KernelToolError("invalid_tool_definition", "positional-only parameters are not supported")
557
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
558
+ raise KernelToolError("invalid_tool_definition", f"*args and **kwargs are not supported ({param.name})")
559
+ names = [param.name for param in params]
560
+ text = _description(fn, description)
561
+ if schema is not None:
562
+ if not isinstance(schema, dict) or schema.get("type") != "object" or not isinstance(schema.get("properties"), dict):
563
+ raise KernelToolError("invalid_tool_definition", "@tool schema must be a JSON object schema")
564
+ if sorted(schema["properties"]) != sorted(names):
565
+ raise KernelToolError("invalid_tool_definition", "@tool schema properties must match parameters")
566
+ return {"name": tool_name, "description": text, "input_schema": schema, "params": names}
567
+ hints = _resolved_hints(fn, params)
568
+ properties: dict[str, Any] = {}
569
+ required: list[str] = []
570
+ for param in params:
571
+ where = f"parameter {param.name!r}"
572
+ prop = _schema_for(hints.get(param.name, param.annotation), where)
573
+ if param.default is inspect.Parameter.empty:
574
+ required.append(param.name)
575
+ else:
576
+ prop = {**prop, "default": _json_default(param.default, where)}
577
+ properties[param.name] = prop
578
+ input_schema: dict[str, Any] = {"type": "object", "properties": properties, "additionalProperties": False}
579
+ if required:
580
+ input_schema["required"] = required
581
+ return {"name": tool_name, "description": text, "input_schema": input_schema, "params": names}
582
+
583
+
584
+ TOKEN_GROUP: contextvars.ContextVar[int | None] = contextvars.ContextVar("senpi_kernel_token_group", default=None)
585
+
586
+
587
+ def _token_key() -> int:
588
+ group = TOKEN_GROUP.get()
589
+ return group if group is not None else threading.get_ident()
590
+
591
+
592
+ class OwnerToken:
593
+ """
594
+ The right to run user code, held by the cell or one kernel tool call at a time. Its key is the thread
595
+ that acquired it, inherited by that thread's parallel()/pipeline() workers, so a host call from any of
596
+ them parks the token for the whole group; the group takes it back once its last host call returns.
597
+ """
598
+
599
+ def __init__(self) -> None:
600
+ self._cond = threading.Condition()
601
+ self._owner: int | None = None
602
+ self._depth = 0
603
+ self._parked: dict[int, list[int]] = {}
604
+
605
+ def acquire(self) -> None:
606
+ me = _token_key()
607
+ with self._cond:
608
+ while self._owner not in (None, me):
609
+ self._cond.wait()
610
+ self._owner = me
611
+ self._depth += 1
612
+
613
+ def release(self) -> None:
614
+ with self._cond:
615
+ if self._owner != _token_key():
616
+ return
617
+ self._depth -= 1
618
+ if self._depth == 0:
619
+ self._owner = None
620
+ self._cond.notify_all()
621
+
622
+ @contextlib.contextmanager
623
+ def parked(self) -> Iterator[None]:
624
+ me = _token_key()
625
+ with self._cond:
626
+ record = self._parked.get(me)
627
+ if self._owner == me:
628
+ self._parked[me] = [1, self._depth]
629
+ self._owner, self._depth = None, 0
630
+ self._cond.notify_all()
631
+ mine = True
632
+ elif record is not None:
633
+ record[0] += 1
634
+ mine = True
635
+ else:
636
+ mine = False
637
+ try:
638
+ yield
639
+ finally:
640
+ if mine:
641
+ with self._cond:
642
+ record = self._parked[me]
643
+ record[0] -= 1
644
+ if record[0] == 0:
645
+ while self._owner is not None:
646
+ self._cond.wait()
647
+ del self._parked[me]
648
+ self._owner, self._depth = me, record[1]
649
+
650
+
651
+ class ThreadRoutedStream(io.TextIOBase):
652
+ """sys.stdout / sys.stderr: each writer thread's text goes to the buffer it registered, else the cell's."""
653
+
654
+ def __init__(self, stderr: bool) -> None:
655
+ self._stderr = stderr
656
+ self._targets: dict[int, io.StringIO] = {}
657
+ # Followed by the parallel()/pipeline() workers a capturing thread starts (they run in a copy of its context).
658
+ self._inherited: contextvars.ContextVar[io.StringIO | None] = contextvars.ContextVar(
659
+ f"senpi_output_{'stderr' if stderr else 'stdout'}", default=None
660
+ )
661
+ self.cell_target: io.StringIO | None = None
662
+
663
+ @contextlib.contextmanager
664
+ def capture(self, buffer: io.StringIO, *, cell: bool) -> Iterator[None]:
665
+ me = threading.get_ident()
666
+ previous = self._targets.get(me)
667
+ self._targets[me] = buffer
668
+ inherited = self._inherited.set(buffer)
669
+ if cell:
670
+ self.cell_target = buffer
671
+ try:
672
+ yield
673
+ finally:
674
+ self._inherited.reset(inherited)
675
+ if previous is None:
676
+ self._targets.pop(me, None)
677
+ else:
678
+ self._targets[me] = previous
679
+ if cell:
680
+ self.cell_target = None
681
+
682
+ def write(self, text: str) -> int:
683
+ target = self._targets.get(threading.get_ident()) or self._inherited.get() or self.cell_target
684
+ if target is None:
685
+ return sys.__stderr__.write(text)
686
+ return target.write(text)
687
+
688
+ def writable(self) -> bool:
689
+ return True
690
+
691
+ @property
692
+ def encoding(self) -> None:
693
+ return None
694
+
695
+
696
+ @dataclass
697
+ class _Entry:
698
+ name: str
699
+ fn: Callable[..., Any]
700
+ description: str
701
+ input_schema: dict[str, Any]
702
+ params: list[str]
703
+ revision: int
704
+
705
+
706
+ class Registry:
707
+ def __init__(self, on_change: Callable[[list[str]], None]) -> None:
708
+ self._lock = threading.Lock()
709
+ self._entries: dict[str, _Entry] = {}
710
+ # Revisions outlive undefine: a redefined name never reuses a revision an old descriptor carries.
711
+ self._revisions: dict[str, int] = {}
712
+ self._on_change = on_change
713
+ self.generation = 1
714
+
715
+ def define(self, fn: Any, *, name: Any = None, description: Any = None, schema: Any = None) -> Any:
716
+ inferred = infer_tool(fn, name=name, description=description, schema=schema)
717
+ key = tool_key(inferred["name"])
718
+ with self._lock:
719
+ existing = self._entries.get(key)
720
+ if existing is not None and existing.name != inferred["name"]:
721
+ raise KernelToolError("tool_name_collision", f"Kernel tool name collides: {inferred['name']}")
722
+ revision = self._revisions.get(key, 0) + 1
723
+ self._revisions[key] = revision
724
+ self._entries[key] = _Entry(fn=fn, revision=revision, **inferred)
725
+ names = sorted(entry.name for entry in self._entries.values())
726
+ self._on_change(names)
727
+ return fn
728
+
729
+ def undefine(self, name: Any) -> bool:
730
+ if not isinstance(name, str):
731
+ return False
732
+ with self._lock:
733
+ removed = self._entries.pop(tool_key(name), None) is not None
734
+ names = sorted(entry.name for entry in self._entries.values())
735
+ if removed:
736
+ self._on_change(names)
737
+ return removed
738
+
739
+ def defined(self) -> list[str]:
740
+ with self._lock:
741
+ return sorted(entry.name for entry in self._entries.values())
742
+
743
+ def describe(self, names: list[str]) -> list[dict[str, Any]]:
744
+ results = []
745
+ with self._lock:
746
+ for name in names:
747
+ entry = self._entries.get(tool_key(name))
748
+ if entry is None:
749
+ error = {"code": "kernel_tool_missing", "message": f"Kernel tool is not defined: {name}"}
750
+ results.append({"name": name, "ok": False, "error": error})
751
+ continue
752
+ descriptor = {"name": entry.name, "description": entry.description, "input_schema": entry.input_schema,
753
+ "language": "py", "kernel_generation": self.generation, "definition_revision": entry.revision}
754
+ results.append({"name": name, "ok": True, "descriptor": descriptor})
755
+ return results
756
+
757
+ def resolve(self, request: dict[str, Any]) -> _Entry:
758
+ if request.get("kernel_generation") != self.generation:
759
+ raise KernelToolError("kernel_tool_stale", "Kernel tool descriptor generation is stale")
760
+ with self._lock:
761
+ entry = self._entries.get(tool_key(str(request.get("name", ""))))
762
+ if entry is None:
763
+ raise KernelToolError("kernel_tool_missing", f"Kernel tool is not defined: {request.get('name')}")
764
+ if entry.revision != request.get("definition_revision"):
765
+ raise KernelToolError("kernel_tool_stale", "Kernel tool descriptor revision is stale")
766
+ return entry
767
+
768
+ def is_live(self, entry: _Entry) -> bool:
769
+ with self._lock:
770
+ return self._entries.get(tool_key(entry.name)) is entry
771
+
772
+
773
+ class Cancelled(BaseException):
774
+ pass
775
+
776
+
777
+ @dataclass
778
+ class Invocation:
779
+ request_id: str
780
+ call_id: str
781
+ scope: Any
782
+ cancelled: threading.Event = field(default_factory=threading.Event)
783
+ task: tuple[asyncio.AbstractEventLoop, asyncio.Task[Any]] | None = None
784
+
785
+
786
+ CURRENT: contextvars.ContextVar[Invocation | None] = contextvars.ContextVar("senpi_kernel_tool_call", default=None)
787
+
788
+
789
+ def host_call_refusal(scope: Any, tool_name: str) -> str | None:
790
+ tools = scope.get("tools") if isinstance(scope, dict) else None
791
+ if not isinstance(tools, dict):
792
+ return None
793
+ deny, allow = tools.get("deny"), tools.get("allow")
794
+ if deny is not None and (not isinstance(deny, list) or tool_name in deny):
795
+ return "deny"
796
+ if allow is not None and (not isinstance(allow, list) or tool_name not in allow):
797
+ return "allow"
798
+ return None
799
+
800
+
801
+ def jsonable(value: Any) -> Any:
802
+ try:
803
+ json.dumps(value, allow_nan=False)
804
+ except (TypeError, ValueError):
805
+ return repr(value)
806
+ return value
807
+
808
+
809
+ def validate_args(schema: dict[str, Any], args: Any) -> dict[str, Any]:
810
+ if not isinstance(args, dict):
811
+ raise KernelToolError("invalid_tool_definition", "kernel tool args must be a JSON object")
812
+ missing = [key for key in schema.get("required", []) if key not in args]
813
+ if missing:
814
+ raise KernelToolError("invalid_tool_definition", f"kernel tool args are missing: {', '.join(missing)}")
815
+ properties = schema.get("properties", {})
816
+ unknown = [key for key in args if key not in properties]
817
+ if unknown:
818
+ raise KernelToolError("invalid_tool_definition", f"kernel tool args are not parameters: {', '.join(unknown)}")
819
+ return args
820
+
821
+
822
+ def is_coroutine_function(fn: Callable[..., Any]) -> bool:
823
+ return inspect.iscoroutinefunction(fn)
824
+
825
+
826
+ CANCELLED_AT_PARK_POINT = "cancelled: took effect at a host park point"
827
+ CANCELLED_AT_AWAIT = "cancelled: took effect at an await point"
828
+ CANCELLED_BEFORE_START = "cancelled: the call never started"
829
+ CANCELLED_RESULT_DROPPED = "cancelled: the call finished after cancellation; its result was dropped"
830
+
831
+
832
+ class KernelToolRunner:
833
+ """Answers describe from the reader thread and runs each invocation on its own thread under the token."""
834
+
835
+ def __init__(
836
+ self,
837
+ emit: Callable[[dict[str, Any]], None],
838
+ token: OwnerToken,
839
+ registry: Registry,
840
+ streams: tuple[ThreadRoutedStream, ThreadRoutedStream],
841
+ ) -> None:
842
+ self._emit = emit
843
+ self._token = token
844
+ self._registry = registry
845
+ self._streams = streams
846
+ self._lock = threading.Lock()
847
+ self._invocations: dict[str, Invocation] = {}
848
+ self._loop: asyncio.AbstractEventLoop | None = None
849
+ self._closed = False
850
+
851
+ def handle(self, message: dict[str, Any]) -> bool:
852
+ kind = message.get("type")
853
+ if kind == "kernel-tool-describe":
854
+ names = [str(name) for name in message.get("names", [])]
855
+ self._emit({"type": "kernel-tool-describe-reply", "requestId": message.get("requestId"), "ok": True,
856
+ "results": self._registry.describe(names)})
857
+ return True
858
+ if kind == "kernel-tool-invoke":
859
+ self._start(message)
860
+ return True
861
+ if kind == "kernel-tool-cancel":
862
+ self._cancel(str(message.get("requestId", "")))
863
+ return True
864
+ return False
865
+
866
+ def close(self) -> None:
867
+ with self._lock:
868
+ self._closed = True
869
+ pending = list(self._invocations.values())
870
+ for invocation in pending:
871
+ invocation.cancelled.set()
872
+
873
+ def _start(self, message: dict[str, Any]) -> None:
874
+ invocation = Invocation(request_id=str(message.get("requestId", "")), call_id=str(message.get("call_id", "")),
875
+ scope=message.get("scope"))
876
+ with self._lock:
877
+ if self._closed:
878
+ self._reply_error(invocation, "tools_unavailable", "Python kernel is closing")
879
+ return
880
+ self._invocations[invocation.request_id] = invocation
881
+ thread = threading.Thread(target=self._run, args=(invocation, message), name="senpi-kernel-tool", daemon=True)
882
+ thread.start()
883
+
884
+ def _cancel(self, request_id: str) -> None:
885
+ with self._lock:
886
+ invocation = self._invocations.get(request_id)
887
+ if invocation is None:
888
+ return
889
+ invocation.cancelled.set()
890
+ if invocation.task is not None:
891
+ loop, task = invocation.task
892
+ loop.call_soon_threadsafe(task.cancel)
893
+
894
+ def _run(self, invocation: Invocation, message: dict[str, Any]) -> None:
895
+ output = io.StringIO()
896
+ try:
897
+ entry = self._registry.resolve(message)
898
+ args = validate_args(entry.input_schema, message.get("args"))
899
+ self._token.acquire()
900
+ try:
901
+ if invocation.cancelled.is_set():
902
+ raise Cancelled(CANCELLED_BEFORE_START)
903
+ CURRENT.set(invocation)
904
+ stdout, stderr = self._streams
905
+ with stdout.capture(output, cell=False), stderr.capture(output, cell=False):
906
+ value = self._call(invocation, entry.fn, args)
907
+ finally:
908
+ CURRENT.set(None)
909
+ self._token.release()
910
+ if invocation.cancelled.is_set():
911
+ raise Cancelled(CANCELLED_RESULT_DROPPED)
912
+ if not self._registry.is_live(entry):
913
+ raise KernelToolError("kernel_tool_stale", "Kernel tool descriptor is stale")
914
+ self._emit({"type": "kernel-tool-invoke-reply", "requestId": invocation.request_id, "ok": True,
915
+ "value": jsonable(value), **self._output_field(output)})
916
+ except Cancelled as exc:
917
+ self._reply_error(invocation, "kernel_tool_cancelled", str(exc), output)
918
+ except KernelToolError as exc:
919
+ self._reply_error(invocation, exc.code, str(exc), output, exc.details)
920
+ except BaseException as exc: # noqa: BROAD_EXCEPT_OK — the invocation boundary serializes the tool's own failure.
921
+ self._reply_error(invocation, "kernel_tool_failed", f"{type(exc).__name__}: {exc}", output,
922
+ stack="".join(traceback.format_exception(type(exc), exc, exc.__traceback__)))
923
+ finally:
924
+ with self._lock:
925
+ self._invocations.pop(invocation.request_id, None)
926
+
927
+ def _call(self, invocation: Invocation, fn: Callable[..., Any], args: dict[str, Any]) -> Any:
928
+ result = fn(**args)
929
+ if not asyncio.iscoroutine(result) and not isinstance(result, asyncio.Future):
930
+ return result
931
+ loop = self._callback_loop()
932
+ temporary = loop.is_running()
933
+ if temporary:
934
+ loop = asyncio.new_event_loop()
935
+ asyncio.set_event_loop(loop)
936
+ task = loop.create_task(_awaited(result))
937
+ invocation.task = (loop, task)
938
+ try:
939
+ return loop.run_until_complete(task)
940
+ except asyncio.CancelledError as exc:
941
+ raise Cancelled(CANCELLED_AT_AWAIT) from exc
942
+ except RuntimeError as exc:
943
+ if "attached to a different loop" in str(exc) or "different event loop" in str(exc):
944
+ raise KernelToolError(
945
+ "kernel_tool_loop_mismatch",
946
+ "an awaited object belongs to another event loop (for example the parent cell's); "
947
+ "create it inside the tool",
948
+ ) from exc
949
+ raise
950
+ finally:
951
+ invocation.task = None
952
+ asyncio.set_event_loop(None)
953
+ if temporary:
954
+ loop.close()
955
+
956
+ def _callback_loop(self) -> asyncio.AbstractEventLoop:
957
+ with self._lock:
958
+ if self._loop is None or self._loop.is_closed():
959
+ self._loop = asyncio.new_event_loop()
960
+ return self._loop
961
+
962
+ def _output_field(self, output: io.StringIO) -> dict[str, Any]:
963
+ text = output.getvalue()
964
+ return {"output": text} if text else {}
965
+
966
+ def _reply_error(self, invocation: Invocation, code: str, message: str, output: io.StringIO | None = None,
967
+ details: Any = None, stack: str | None = None) -> None:
968
+ error: dict[str, Any] = {"code": code, "message": message}
969
+ if details is not None:
970
+ error["details"] = details
971
+ if stack is not None:
972
+ error["stack"] = stack
973
+ frame: dict[str, Any] = {"type": "kernel-tool-invoke-reply", "requestId": invocation.request_id, "ok": False,
974
+ "error": error}
975
+ if output is not None:
976
+ frame.update(self._output_field(output))
977
+ self._emit(frame)
978
+
979
+
980
+ async def _awaited(awaitable: Any) -> Any:
981
+ return await awaitable
982
+
983
+
984
+ KERNEL_TOOL_TOKEN = OwnerToken()
985
+ KERNEL_TOOL_STREAMS = (ThreadRoutedStream(stderr=False), ThreadRoutedStream(stderr=True))
986
+ KERNEL_TOOL_REGISTRY = Registry(lambda names: emit({"type": "kernel-tools-defined", "names": names}))
987
+ KERNEL_TOOL_RUNNER = KernelToolRunner(emit, KERNEL_TOOL_TOKEN, KERNEL_TOOL_REGISTRY, KERNEL_TOOL_STREAMS)
988
+
989
+
990
+ def _prelude_tool_error(exc: KernelToolError) -> PreludeRuntimeError:
991
+ return PreludeRuntimeError(f"{exc.code}: {exc}", exc.code)
992
+
993
+
994
+ def _guard_host_call(payload: dict[str, Any]) -> None:
995
+ invocation = CURRENT.get()
996
+ if invocation is None:
997
+ return
998
+ if invocation.cancelled.is_set():
999
+ raise Cancelled(CANCELLED_AT_PARK_POINT)
1000
+ tool_name = payload.get("toolName")
1001
+ refusal = host_call_refusal(invocation.scope, tool_name) if isinstance(tool_name, str) else None
1002
+ if refusal is not None:
1003
+ raise KernelToolError(
1004
+ "kernel_tool_host_denied",
1005
+ f"Host tool is outside this kernel tool call's scope: {tool_name} ({refusal})",
1006
+ {"tool": tool_name, "call_id": invocation.call_id, "reason": refusal},
1007
+ )
1008
+
1009
+
405
1010
  class ToolCallable:
406
1011
  __slots__ = ("_name",)
407
1012
 
@@ -430,6 +1035,21 @@ class ToolCallable:
430
1035
  class ToolProxy:
431
1036
  __slots__ = ()
432
1037
 
1038
+ def __call__(self, fn: Any = None, /, *, name: Any = None, description: Any = None, schema: Any = None) -> Any:
1039
+ def register(target: Any) -> Any:
1040
+ try:
1041
+ return KERNEL_TOOL_REGISTRY.define(target, name=name, description=description, schema=schema)
1042
+ except KernelToolError as exc:
1043
+ raise _prelude_tool_error(exc) from None
1044
+
1045
+ return register if fn is None else register(fn)
1046
+
1047
+ def defined(self) -> list[str]:
1048
+ return KERNEL_TOOL_REGISTRY.defined()
1049
+
1050
+ def undefine(self, name: Any) -> bool:
1051
+ return KERNEL_TOOL_REGISTRY.undefine(name)
1052
+
433
1053
  def __getattr__(self, name: str) -> ToolCallable:
434
1054
  if name.startswith("_"):
435
1055
  raise AttributeError(name)
@@ -477,10 +1097,18 @@ class Workpool:
477
1097
  return _workpool_call({"op": "cancel", "pool_id": self.pool_id})
478
1098
 
479
1099
 
480
- def workpool(agent: dict[str, JsonValue], name: str, *, mode: str | None = None) -> Workpool:
1100
+ def workpool(
1101
+ agent: dict[str, JsonValue], name: str, *, mode: str | None = None, tools: list[str] | None = None
1102
+ ) -> Workpool:
481
1103
  args: dict[str, JsonValue] = {"op": "create", "agent": agent, "name": name}
482
1104
  if mode is not None:
483
1105
  args["mode"] = mode
1106
+ if tools is not None:
1107
+ if not isinstance(tools, (list, tuple)) or not all(isinstance(name, str) for name in tools):
1108
+ raise PreludeRuntimeError(
1109
+ f"workpool(tools=...) takes a list of tool names; got {type(tools).__name__}", "invalid_tools"
1110
+ )
1111
+ args["tools"] = list(tools)
484
1112
  result = _workpool_call(args)
485
1113
  details = result.get("details")
486
1114
  if isinstance(details, dict):
@@ -558,9 +1186,12 @@ def agent(
558
1186
  apply: bool | None = None,
559
1187
  merge: bool | str | None = None,
560
1188
  handle: bool = False,
1189
+ tools: list[str] | None = None,
561
1190
  ) -> Any:
562
1191
  """Delegate work; isolated/apply/merge need a host that supports isolation, otherwise a warning.
563
1192
 
1193
+ tools grants the child this kernel's @tool functions by name, as JavaScript's agent(prompt, { tools }) does.
1194
+
564
1195
  merge accepts "patch"/"branch" or False/True respectively. Unapplied foreground
565
1196
  changes raise an error with recovery instructions. A handle returns immediately;
566
1197
  await the completion notification or read task_output for the isolation result.
@@ -582,6 +1213,10 @@ def agent(
582
1213
  args["merge"] = merge
583
1214
  if handle:
584
1215
  args["handle"] = True
1216
+ if tools is not None:
1217
+ if not isinstance(tools, (list, tuple)) or not all(isinstance(name, str) for name in tools):
1218
+ raise PreludeRuntimeError(f"agent(tools=...) takes a list of tool names; got {type(tools).__name__}", "invalid_tools")
1219
+ args["tools"] = list(tools)
585
1220
 
586
1221
  response = bridge_post(
587
1222
  "/call",
@@ -763,8 +1398,17 @@ def _pool_map(items: Iterable[Any], function: Callable[[Any], Any]) -> list[Any]
763
1398
  workers = min(max(1, width), len(values))
764
1399
  results: list[Any] = [None] * len(values)
765
1400
  errors: dict[int, BaseException] = {}
1401
+ group = _token_key()
1402
+
1403
+ def submit(pool: ThreadPoolExecutor, value: Any) -> Any:
1404
+ # Each worker runs in a copy of this thread's context: the kernel tool call it serves (scope),
1405
+ # its output buffer, and its token group, so its host calls park this thread's token.
1406
+ context = contextvars.copy_context()
1407
+ context.run(TOKEN_GROUP.set, group)
1408
+ return pool.submit(context.run, function, value)
1409
+
766
1410
  with ThreadPoolExecutor(max_workers=workers) as pool:
767
- futures = {pool.submit(function, value): index for index, value in enumerate(values)}
1411
+ futures = {submit(pool, value): index for index, value in enumerate(values)}
768
1412
  for future in as_completed(futures):
769
1413
  index = futures[future]
770
1414
  try:
@@ -957,8 +1601,14 @@ _LINE_MAGICS: dict[str, Callable[[str], Any]] = {
957
1601
  }
958
1602
 
959
1603
 
1604
+ _HOST_MAGICS = frozenset({"pip", "environment"})
1605
+
1606
+
960
1607
  def _magic(name: str, args: str) -> Any:
961
1608
  handler = _LINE_MAGICS.get(name)
1609
+ if handler is None and name in _HOST_MAGICS:
1610
+ # The host runs these only as a cell's first code line; anywhere else they reach here.
1611
+ raise PreludeRuntimeError(f"put %{name} on its own cell, then run the code that uses it in the next cell")
962
1612
  if handler is None:
963
1613
  raise PreludeRuntimeError(f"Unsupported line magic: %{name}")
964
1614
  return handler(args)
@@ -1449,8 +2099,8 @@ KERNEL_MEMORY = _KernelMemory()
1449
2099
  TLA_FLAG = getattr(ast, "PyCF_ALLOW_TOP_LEVEL_AWAIT", 0x2000)
1450
2100
 
1451
2101
 
1452
- def compile_cell(source: str) -> tuple[Any | None, Any | None]:
1453
- module = ast.parse(transform_cell(source), mode="exec")
2102
+ def compile_cell(source: str, filename: str = "<cell>") -> tuple[Any | None, Any | None]:
2103
+ module = ast.parse(transform_cell(source), filename=filename, mode="exec")
1454
2104
  if not module.body:
1455
2105
  return None, None
1456
2106
  last = module.body[-1]
@@ -1458,13 +2108,13 @@ def compile_cell(source: str) -> tuple[Any | None, Any | None]:
1458
2108
  body = ast.Module(body=module.body[:-1], type_ignores=[])
1459
2109
  expression = ast.Expression(body=last.value)
1460
2110
  ast.copy_location(expression, last)
1461
- return compile(body, "<cell>", "exec", flags=TLA_FLAG), compile(
2111
+ return compile(body, filename, "exec", flags=TLA_FLAG), compile(
1462
2112
  expression,
1463
- "<cell>",
2113
+ filename,
1464
2114
  "eval",
1465
2115
  flags=TLA_FLAG,
1466
2116
  )
1467
- return compile(module, "<cell>", "exec", flags=TLA_FLAG), None
2117
+ return compile(module, filename, "exec", flags=TLA_FLAG), None
1468
2118
 
1469
2119
 
1470
2120
  async def run_code(code: Any, want_value: bool) -> Any:
@@ -1491,7 +2141,41 @@ def apply_preludes(preludes: Any) -> None:
1491
2141
  exec(compile(contribution.get("python", ""), "<kernel-prelude>", "exec"), USER_NS)
1492
2142
 
1493
2143
 
1494
- def run_cell(cell_id: str, code: str, preludes: Any = None) -> None:
2144
+ def _enter_source_file(source_file: str | None) -> Callable[[], None]:
2145
+ # A %load cell runs as its file: __file__ names it and its directory comes first on the import path,
2146
+ # so `from sibling import x` resolves next to the file the way it does when the file runs as a script.
2147
+ # The returned step undoes both when the cell ends, so a later cell never imports from that directory
2148
+ # ahead of the session environment; the file's own sys.path edits stay.
2149
+ if source_file is None:
2150
+ return lambda: None
2151
+ missing = object()
2152
+ previous_file = USER_NS.get("__file__", missing)
2153
+ directory = os.path.dirname(source_file)
2154
+ previous_index = sys.path.index(directory) if directory in sys.path else None
2155
+ with contextlib.suppress(ValueError):
2156
+ sys.path.remove(directory)
2157
+ sys.path.insert(0, directory)
2158
+ importlib.invalidate_caches()
2159
+ USER_NS["__file__"] = source_file
2160
+
2161
+ def restore() -> None:
2162
+ with contextlib.suppress(ValueError):
2163
+ sys.path.remove(directory)
2164
+ if previous_index is not None:
2165
+ sys.path.insert(min(previous_index, len(sys.path)), directory)
2166
+ if previous_file is missing:
2167
+ USER_NS.pop("__file__", None)
2168
+ else:
2169
+ USER_NS["__file__"] = previous_file
2170
+ # Last: it calls every sys.meta_path finder, and user code may have installed one that raises.
2171
+ importlib.invalidate_caches()
2172
+
2173
+ return restore
2174
+
2175
+
2176
+ def run_cell(
2177
+ cell_id: str, code: str, preludes: Any = None, source_file: str | None = None, cell_token: str | None = None
2178
+ ) -> None:
1495
2179
  start = time.monotonic()
1496
2180
  stdout = io.StringIO()
1497
2181
  stderr = io.StringIO()
@@ -1499,10 +2183,15 @@ def run_cell(cell_id: str, code: str, preludes: Any = None) -> None:
1499
2183
  # cells) ignores it so a late signal cannot kill the stdin-read loop.
1500
2184
  signal.signal(signal.SIGINT, signal.default_int_handler)
1501
2185
  result: dict[str, Any]
2186
+ leave_source_file: Callable[[], None] = lambda: None
2187
+ cell_scope = CURRENT_CELL_TOKEN.set(cell_token)
1502
2188
  try:
1503
- with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
2189
+ KERNEL_TOOL_TOKEN.acquire()
2190
+ cell_stdout, cell_stderr = KERNEL_TOOL_STREAMS
2191
+ with cell_stdout.capture(stdout, cell=True), cell_stderr.capture(stderr, cell=True):
1504
2192
  apply_preludes(preludes)
1505
- body, expression = compile_cell(code)
2193
+ leave_source_file = _enter_source_file(source_file)
2194
+ body, expression = compile_cell(code, source_file or "<cell>")
1506
2195
  LOOP.run_until_complete(run_code(body, False))
1507
2196
  value = LOOP.run_until_complete(run_code(expression, True))
1508
2197
  text("stdout", stdout.getvalue())
@@ -1526,6 +2215,13 @@ def run_cell(cell_id: str, code: str, preludes: Any = None) -> None:
1526
2215
  "durationMs": elapsed(start),
1527
2216
  }
1528
2217
  finally:
2218
+ # Liveness and revocation first: nothing the cell installed (an import finder, a path hook) can skip them.
2219
+ KERNEL_TOOL_TOKEN.release()
2220
+ CURRENT_CELL_TOKEN.reset(cell_scope)
2221
+ try:
2222
+ leave_source_file()
2223
+ except BaseException as exc: # noqa: BROAD_EXCEPT_OK — user code can break the restore; it must not wedge the kernel.
2224
+ text("stderr", f"[senpi] %load could not fully restore the import path: {exc}\n")
1529
2225
  signal.signal(signal.SIGINT, signal.SIG_IGN)
1530
2226
  try:
1531
2227
  memory = KERNEL_MEMORY.after_cell()
@@ -1550,18 +2246,52 @@ def _handle_message(message: dict[str, Any]) -> bool:
1550
2246
  emit({"type": "init-failed", "error": {"message": "missing bridge connection"}})
1551
2247
  return True
1552
2248
  CONNECTION = connection
2249
+ generation = message.get("kernelGeneration")
2250
+ if isinstance(generation, int) and not isinstance(generation, bool):
2251
+ KERNEL_TOOL_REGISTRY.generation = generation
1553
2252
  KERNEL_MEMORY.configure(message.get("memory"))
1554
2253
  emit({"type": "ready"})
1555
2254
  return True
1556
2255
  if message_type == "run":
1557
- run_cell(str(message.get("cellId", "")), str(message.get("code", "")), message.get("preludes"))
2256
+ env_root = message.get("envRoot")
2257
+ if isinstance(env_root, str):
2258
+ _activate_env_root(env_root)
2259
+ source_file = message.get("sourceFile")
2260
+ token = message.get("bridgeCellToken")
2261
+ run_cell(
2262
+ str(message.get("cellId", "")),
2263
+ str(message.get("code", "")),
2264
+ message.get("preludes"),
2265
+ source_file if isinstance(source_file, str) and source_file else None,
2266
+ token if isinstance(token, str) else None,
2267
+ )
1558
2268
  return True
1559
2269
  if message_type == "close":
2270
+ KERNEL_TOOL_RUNNER.close()
1560
2271
  emit({"type": "closed"})
1561
2272
  return False
1562
2273
  return True
1563
2274
 
1564
2275
 
2276
+ _ACTIVE_ENV_ROOT: list[str] = []
2277
+
2278
+
2279
+ def _activate_env_root(root: str) -> None:
2280
+ # One environment revision is on the import path at a time: a newer revision already holds every
2281
+ # package of the previous one, so it replaces that entry instead of stacking another.
2282
+ # An empty root means the session's current mode has nothing installed: the previous revision leaves too.
2283
+ wanted = [root] if root else []
2284
+ if _ACTIVE_ENV_ROOT == wanted:
2285
+ return
2286
+ for previous in _ACTIVE_ENV_ROOT:
2287
+ with contextlib.suppress(ValueError):
2288
+ sys.path.remove(previous)
2289
+ _ACTIVE_ENV_ROOT[:] = wanted
2290
+ if root:
2291
+ sys.path.insert(0, root)
2292
+ importlib.invalidate_caches()
2293
+
2294
+
1565
2295
  def _terminate_process_group() -> None:
1566
2296
  # The kernel is spawned into its own session (setsid), so its pid is its process
1567
2297
  # group id and a cell's subprocesses inherit that group. Killing the group takes
@@ -1612,14 +2342,37 @@ def _start_parent_watch() -> None:
1612
2342
  ).start()
1613
2343
 
1614
2344
 
2345
+ def _read_control(commands: queue.Queue[tuple[str, Any]]) -> None:
2346
+ # Kernel-tool frames are served here, so a callback can be admitted while the main thread is inside a cell.
2347
+ for raw in sys.stdin:
2348
+ try:
2349
+ message = json.loads(raw)
2350
+ except BaseException as exc: # noqa: BROAD_EXCEPT_OK — malformed input is reported by the main loop, in order.
2351
+ commands.put(("malformed", exc))
2352
+ continue
2353
+ if isinstance(message, dict) and KERNEL_TOOL_RUNNER.handle(message):
2354
+ continue
2355
+ commands.put(("message", message))
2356
+ commands.put(("eof", None))
2357
+
2358
+
1615
2359
  def main() -> None:
1616
2360
  emit_status("kernel-startup", force=True, stage="host-init")
1617
2361
  signal.signal(signal.SIGINT, signal.SIG_IGN)
1618
2362
  _start_parent_watch()
2363
+ sys.stdout, sys.stderr = KERNEL_TOOL_STREAMS
2364
+ commands: queue.Queue[tuple[str, Any]] = queue.Queue()
2365
+ threading.Thread(target=_read_control, args=(commands,), name="senpi-control-reader", daemon=True).start()
1619
2366
  host_closed = False
1620
- for raw in sys.stdin:
2367
+ while True:
2368
+ kind, payload = commands.get()
2369
+ if kind == "eof":
2370
+ break
2371
+ if kind == "malformed":
2372
+ emit({"type": "init-failed", "error": bridge_error(payload)})
2373
+ continue
1621
2374
  try:
1622
- if not _handle_message(json.loads(raw)):
2375
+ if not _handle_message(payload):
1623
2376
  host_closed = True
1624
2377
  break
1625
2378
  except BaseException as exc: # noqa: BROAD_EXCEPT_OK — process boundary serializes malformed input and interrupts.