@code-yeongyu/senpi-codemode 2026.10.6 → 2026.10.8

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 (72) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +20 -2
  3. package/package.json +5 -4
  4. package/src/bridge/kernel-tools-protocol.ts +8 -0
  5. package/src/bridge/reserved.ts +10 -0
  6. package/src/bridges/eval-virtual-schemas.ts +100 -0
  7. package/src/bridges/handle-bridge.ts +72 -0
  8. package/src/bridges/reserved-dispatch.ts +20 -2
  9. package/src/bridges/schema-bridge.ts +4 -0
  10. package/src/completion/handle.ts +69 -0
  11. package/src/completion/tool-bridge.ts +31 -12
  12. package/src/environments/editable-check.ts +116 -0
  13. package/src/environments/install-lock.ts +223 -0
  14. package/src/environments/py-environment.ts +98 -0
  15. package/src/environments/py-installer.ts +187 -0
  16. package/src/environments/revision-store.ts +95 -0
  17. package/src/extension/bridge-dispatch.ts +41 -0
  18. package/src/extension/bridge-tool-call.ts +5 -1
  19. package/src/extension/host-facts.ts +18 -0
  20. package/src/extension/kernel-registration.ts +5 -0
  21. package/src/extension/removed-tool-hints.ts +15 -0
  22. package/src/extension/runtime-factory.ts +3 -0
  23. package/src/extension/session-manager-contract.ts +3 -0
  24. package/src/extension/session-manager.ts +32 -5
  25. package/src/extension/start-recovery.ts +111 -0
  26. package/src/handles/completion-handles.ts +202 -0
  27. package/src/handles/handle-args.ts +103 -0
  28. package/src/handles/handle-registry.ts +164 -0
  29. package/src/handles/wait.ts +214 -0
  30. package/src/handles/watch-queue.ts +102 -0
  31. package/src/index.ts +99 -94
  32. package/src/kernels/jl/handles.jl +124 -0
  33. package/src/kernels/jl/prelude.jl +9 -0
  34. package/src/kernels/jl/runner.jl +6 -1
  35. package/src/kernels/js/handles.js +93 -0
  36. package/src/kernels/js/kernel-tools-define.d.ts +6 -0
  37. package/src/kernels/js/kernel-tools-define.js +3 -1
  38. package/src/kernels/js/kernel-tools-errors.js +2 -0
  39. package/src/kernels/js/kernel-tools-errors.ts +2 -0
  40. package/src/kernels/js/kernel-tools-registry.d.ts +2 -0
  41. package/src/kernels/js/kernel-tools-registry.js +18 -9
  42. package/src/kernels/js/kernel-tools-schema.js +6 -2
  43. package/src/kernels/js/worker-runtime.js +10 -1
  44. package/src/kernels/py/host-cell.ts +42 -0
  45. package/src/kernels/py/kernel-contract.ts +13 -2
  46. package/src/kernels/py/kernel-tools-host.ts +97 -0
  47. package/src/kernels/py/kernel.ts +88 -12
  48. package/src/kernels/py/prelude.py +797 -11
  49. package/src/kernels/py/process.ts +15 -4
  50. package/src/kernels/py/startup.ts +58 -20
  51. package/src/kernels/py/transport.ts +36 -7
  52. package/src/kernels/rb/handles.rb +97 -0
  53. package/src/kernels/rb/paths.rb +37 -0
  54. package/src/kernels/rb/prelude.rb +15 -38
  55. package/src/kernels/rb/runner.rb +7 -7
  56. package/src/kernels/sandbox/vendor/pi-codemode/LICENSE +21 -0
  57. package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +42 -0
  58. package/src/kernels/sandbox/vendor/pi-codemode/identifier.ts +14 -0
  59. package/src/kernels/sandbox/vendor/pi-codemode/runtime/host.ts +514 -0
  60. package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +431 -0
  61. package/src/kernels/sandbox/vendor/pi-codemode/runtime/protocol.ts +61 -0
  62. package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +231 -0
  63. package/src/kernels/sandbox/vendor/pi-codemode/types.ts +185 -0
  64. package/src/kernels/sandbox/vendor/pi-codemode/wasm.ts +37 -0
  65. package/src/kernels/shared/kernel-tools-pump.ts +6 -0
  66. package/src/kernels/shared/process-cpu.ts +69 -0
  67. package/src/kernels/shared/subprocess-queue.ts +1 -0
  68. package/src/tool/cell-handler.ts +21 -18
  69. package/src/tool/cell-reserved-dispatch.ts +51 -0
  70. package/src/tool/eval-tool-options.ts +3 -0
  71. package/src/tool/run-eval-cell.ts +2 -0
  72. package/src/tool/types.ts +15 -0
@@ -18,6 +18,7 @@ import inspect
18
18
  import io
19
19
  import json
20
20
  import locale
21
+ import math
21
22
  import os
22
23
  import re
23
24
  import signal
@@ -25,12 +26,18 @@ import subprocess
25
26
  import time
26
27
  import traceback
27
28
  import types
29
+ import typing
28
30
  import urllib.error
29
31
  import urllib.request
30
32
  import uuid
31
33
  from collections.abc import Iterable
32
34
  from concurrent.futures import ThreadPoolExecutor, as_completed
33
35
  from pathlib import Path
36
+ import contextvars
37
+ import queue
38
+ import threading
39
+ from dataclasses import dataclass, field
40
+ from typing import Iterator
34
41
  from threading import Lock, Thread
35
42
  from typing import Any, Callable, Union
36
43
  from urllib.parse import unquote
@@ -48,6 +55,11 @@ EMIT_LOCK = Lock()
48
55
  RESERVED_AGENT_TOOL = "__agent__"
49
56
  RESERVED_OUTPUT_TOOL = "__output__"
50
57
  RESERVED_SCHEMA_TOOL = "__schema__"
58
+ RESERVED_WAIT_TOOL = "__wait__"
59
+ RESERVED_HANDLE_STATUS_TOOL = "__handle_status__"
60
+ RESERVED_HANDLE_OUTPUT_TOOL = "__handle_output__"
61
+ RESERVED_HANDLE_SEND_TOOL = "__handle_send__"
62
+ RESERVED_HANDLE_CANCEL_TOOL = "__handle_cancel__"
51
63
  TIMEOUT_PAUSE_OP = "timeout-pause"
52
64
  TIMEOUT_RESUME_OP = "timeout-resume"
53
65
 
@@ -357,9 +369,12 @@ def write(path: str | Path, content: str) -> Path:
357
369
  # The bridge is a loopback call: urllib's default opener would route it through a configured proxy
358
370
  # (the environment everywhere, the registry on Windows), which a 127.0.0.1 request must never use.
359
371
  _BRIDGE_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
372
+ # Ordinary bridge calls keep this socket timeout; only `__wait__` passes its own bound (see `_bridge_wait_post`).
373
+ _BRIDGE_SOCKET_TIMEOUT_SECONDS = 60
374
+ _WAIT_SOCKET_GRACE_SECONDS = 30
360
375
 
361
376
 
362
- def bridge_post(path: str, payload: dict[str, Any]) -> Any:
377
+ def bridge_post(path: str, payload: dict[str, Any], *, socket_timeout: float | None = _BRIDGE_SOCKET_TIMEOUT_SECONDS) -> Any:
363
378
  port = CONNECTION.get("port")
364
379
  token = CONNECTION.get("token")
365
380
  if not isinstance(port, int) or not isinstance(token, str):
@@ -371,15 +386,20 @@ def bridge_post(path: str, payload: dict[str, Any]) -> Any:
371
386
  headers={"authorization": f"Bearer {token}", "content-type": "application/json"},
372
387
  method="POST",
373
388
  )
389
+ _guard_host_call(payload)
374
390
  emit_status(TIMEOUT_PAUSE_OP, force=True)
375
391
  try:
376
- try:
377
- with _BRIDGE_OPENER.open(request, timeout=60) as response:
378
- response_data = response.read()
379
- except urllib.error.HTTPError as exc:
380
- response_data = exc.read()
392
+ with KERNEL_TOOL_TOKEN.parked():
393
+ try:
394
+ with _BRIDGE_OPENER.open(request, timeout=socket_timeout) as response:
395
+ response_data = response.read()
396
+ except urllib.error.HTTPError as exc:
397
+ response_data = exc.read()
381
398
  finally:
382
399
  emit_status(TIMEOUT_RESUME_OP, force=True)
400
+ invocation = CURRENT.get()
401
+ if invocation is not None and invocation.cancelled.is_set():
402
+ raise Cancelled(CANCELLED_AT_PARK_POINT)
383
403
 
384
404
  try:
385
405
  body = json.loads(response_data.decode("utf-8"))
@@ -393,6 +413,591 @@ def bridge_post(path: str, payload: dict[str, Any]) -> Any:
393
413
  raise PreludeRuntimeError(str(error))
394
414
 
395
415
 
416
+ # --- Kernel tools (`@tool`): inference, admission token, output routing, registry and serving threads. ---
417
+ # Admission is one interpreter-owner token: the main thread holds it while a cell runs and releases it only inside a
418
+ # host bridge call or while idle; a callback runs only while holding it, so a cell busy in pure computation never yields.
419
+
420
+ MCP_TOOL_NAME_MAX_LENGTH = 64
421
+ _NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
422
+ RESERVED_TOOL_NAMES = frozenset({"__agent__", "__output__", "__schema__", "defined", "undefine"})
423
+ _NONE_TYPE = type(None)
424
+
425
+
426
+ class KernelToolError(Exception):
427
+ def __init__(self, code: str, message: str, details: dict[str, Any] | None = None) -> None:
428
+ super().__init__(message)
429
+ self.code = code
430
+ self.details = details
431
+
432
+
433
+ def tool_key(name: str) -> str:
434
+ return re.sub(r"[^a-zA-Z0-9_-]", "_", name).replace("-", "_")
435
+
436
+
437
+ def validate_tool_name(name: Any) -> str:
438
+ if not isinstance(name, str) or not name:
439
+ raise KernelToolError("invalid_tool_definition", "@tool name must be a non-empty string")
440
+ if not _NAME_RE.match(name) or len(name) > MCP_TOOL_NAME_MAX_LENGTH:
441
+ raise KernelToolError("invalid_tool_definition", f"Kernel tool name must match MCP name grammar: {name}")
442
+ if tool_key(name) in {tool_key(reserved) for reserved in RESERVED_TOOL_NAMES}:
443
+ raise KernelToolError("reserved_tool_name", f"Kernel tool name is reserved: {name}")
444
+ return name
445
+
446
+
447
+ def _resolved_hints(fn: Callable[..., Any], params: list[inspect.Parameter]) -> dict[str, Any]:
448
+ # Only parameters shape the input schema; the return annotation may name a class defined later.
449
+ def parameters_only() -> None: ...
450
+
451
+ parameters_only.__annotations__ = {
452
+ param.name: param.annotation for param in params if param.annotation is not inspect.Parameter.empty
453
+ }
454
+ try:
455
+ return typing.get_type_hints(parameters_only, globalns=getattr(fn, "__globals__", None), include_extras=True)
456
+ except Exception as exc: # noqa: BROAD_EXCEPT_OK — any resolution failure is reported with the annotation that caused it.
457
+ globalns = getattr(fn, "__globals__", {})
458
+ for param in params:
459
+ annotation = getattr(param.annotation, "__forward_arg__", param.annotation)
460
+ if not isinstance(annotation, str):
461
+ continue
462
+ try:
463
+ eval(annotation, globalns) # noqa: S307 — the same evaluation get_type_hints performs, one annotation at a time.
464
+ except Exception as inner: # noqa: BROAD_EXCEPT_OK — names the annotation that failed.
465
+ raise KernelToolError(
466
+ "invalid_tool_definition",
467
+ f"can't resolve the annotation {annotation!r} on parameter {param.name!r} ({inner}); pass schema=",
468
+ ) from inner
469
+ raise KernelToolError("invalid_tool_definition", f"can't resolve the type hints ({exc}); pass schema=") from exc
470
+
471
+
472
+ def _schema_for(annotation: Any, where: str) -> dict[str, Any]:
473
+ if annotation is inspect.Parameter.empty or annotation is typing.Any:
474
+ return {}
475
+ origin = typing.get_origin(annotation)
476
+ args = typing.get_args(annotation)
477
+ if origin is typing.Annotated:
478
+ schema = _schema_for(args[0], where)
479
+ description = next((item for item in args[1:] if isinstance(item, str)), None)
480
+ return {**schema, "description": description} if description is not None else schema
481
+ if annotation is _NONE_TYPE or annotation is None:
482
+ return {"type": "null"}
483
+ if annotation is bool:
484
+ return {"type": "boolean"}
485
+ if annotation is int:
486
+ return {"type": "integer"}
487
+ if annotation is float:
488
+ return {"type": "number"}
489
+ if annotation is str:
490
+ return {"type": "string"}
491
+ if annotation is list or annotation is typing.List:
492
+ return {"type": "array"}
493
+ if annotation is dict or annotation is typing.Dict:
494
+ return {"type": "object"}
495
+ if origin is list:
496
+ return {"type": "array", "items": _schema_for(args[0], where)} if args else {"type": "array"}
497
+ if origin is dict:
498
+ if args and args[0] is not str:
499
+ raise KernelToolError("invalid_tool_definition", f"{where}: dict keys must be str")
500
+ return {"type": "object", "additionalProperties": _schema_for(args[1], where)} if args else {"type": "object"}
501
+ if origin is typing.Literal:
502
+ if not all(isinstance(value, (str, int, float, bool)) or value is None for value in args):
503
+ raise KernelToolError("invalid_tool_definition", f"{where}: Literal values must be JSON scalars")
504
+ return {"enum": list(args)}
505
+ if origin is typing.Union or origin is getattr(types, "UnionType", None):
506
+ return {"anyOf": [_schema_for(item, where) for item in args]}
507
+ raise KernelToolError("invalid_tool_definition", f"{where}: unsupported annotation {annotation!r}; pass schema=")
508
+
509
+
510
+ def _json_default(value: Any, where: str) -> Any:
511
+ if callable(value):
512
+ raise KernelToolError("invalid_tool_definition", f"{where}: default must be a JSON value, not a callable")
513
+ try:
514
+ json.dumps(value, allow_nan=False)
515
+ except (TypeError, ValueError) as exc:
516
+ raise KernelToolError("invalid_tool_definition", f"{where}: default must be a JSON value ({exc})") from exc
517
+ return value
518
+
519
+
520
+ def _description(fn: Callable[..., Any], explicit: Any) -> str:
521
+ if explicit is not None:
522
+ if not isinstance(explicit, str):
523
+ raise KernelToolError("invalid_tool_definition", "@tool description must be a string")
524
+ return explicit
525
+ doc = inspect.getdoc(fn) or ""
526
+ return doc.split("\n\n", 1)[0].strip()
527
+
528
+
529
+ def _signature(fn: Callable[..., Any]) -> inspect.Signature:
530
+ # Python 3.14 evaluates annotations lazily; asking for forward references keeps a return type that names
531
+ # a class defined later from failing here.
532
+ try:
533
+ import annotationlib
534
+
535
+ return inspect.signature(fn, annotation_format=annotationlib.Format.FORWARDREF)
536
+ except ImportError:
537
+ return inspect.signature(fn)
538
+
539
+
540
+ def infer_tool(fn: Any, *, name: Any = None, description: Any = None, schema: Any = None) -> dict[str, Any]:
541
+ if not callable(fn) or not hasattr(fn, "__name__"):
542
+ raise KernelToolError("invalid_tool_definition", "@tool requires a named function")
543
+ tool_name = validate_tool_name(fn.__name__ if name is None else name)
544
+ params = list(_signature(fn).parameters.values())
545
+ for param in params:
546
+ if param.kind is inspect.Parameter.POSITIONAL_ONLY:
547
+ raise KernelToolError("invalid_tool_definition", "positional-only parameters are not supported")
548
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
549
+ raise KernelToolError("invalid_tool_definition", f"*args and **kwargs are not supported ({param.name})")
550
+ names = [param.name for param in params]
551
+ text = _description(fn, description)
552
+ if schema is not None:
553
+ if not isinstance(schema, dict) or schema.get("type") != "object" or not isinstance(schema.get("properties"), dict):
554
+ raise KernelToolError("invalid_tool_definition", "@tool schema must be a JSON object schema")
555
+ if sorted(schema["properties"]) != sorted(names):
556
+ raise KernelToolError("invalid_tool_definition", "@tool schema properties must match parameters")
557
+ return {"name": tool_name, "description": text, "input_schema": schema, "params": names}
558
+ hints = _resolved_hints(fn, params)
559
+ properties: dict[str, Any] = {}
560
+ required: list[str] = []
561
+ for param in params:
562
+ where = f"parameter {param.name!r}"
563
+ prop = _schema_for(hints.get(param.name, param.annotation), where)
564
+ if param.default is inspect.Parameter.empty:
565
+ required.append(param.name)
566
+ else:
567
+ prop = {**prop, "default": _json_default(param.default, where)}
568
+ properties[param.name] = prop
569
+ input_schema: dict[str, Any] = {"type": "object", "properties": properties, "additionalProperties": False}
570
+ if required:
571
+ input_schema["required"] = required
572
+ return {"name": tool_name, "description": text, "input_schema": input_schema, "params": names}
573
+
574
+
575
+ TOKEN_GROUP: contextvars.ContextVar[int | None] = contextvars.ContextVar("senpi_kernel_token_group", default=None)
576
+
577
+
578
+ def _token_key() -> int:
579
+ group = TOKEN_GROUP.get()
580
+ return group if group is not None else threading.get_ident()
581
+
582
+
583
+ class OwnerToken:
584
+ """
585
+ The right to run user code, held by the cell or one kernel tool call at a time. Its key is the thread
586
+ that acquired it, inherited by that thread's parallel()/pipeline() workers, so a host call from any of
587
+ them parks the token for the whole group; the group takes it back once its last host call returns.
588
+ """
589
+
590
+ def __init__(self) -> None:
591
+ self._cond = threading.Condition()
592
+ self._owner: int | None = None
593
+ self._depth = 0
594
+ self._parked: dict[int, list[int]] = {}
595
+
596
+ def acquire(self) -> None:
597
+ me = _token_key()
598
+ with self._cond:
599
+ while self._owner not in (None, me):
600
+ self._cond.wait()
601
+ self._owner = me
602
+ self._depth += 1
603
+
604
+ def release(self) -> None:
605
+ with self._cond:
606
+ if self._owner != _token_key():
607
+ return
608
+ self._depth -= 1
609
+ if self._depth == 0:
610
+ self._owner = None
611
+ self._cond.notify_all()
612
+
613
+ @contextlib.contextmanager
614
+ def parked(self) -> Iterator[None]:
615
+ me = _token_key()
616
+ with self._cond:
617
+ record = self._parked.get(me)
618
+ if self._owner == me:
619
+ self._parked[me] = [1, self._depth]
620
+ self._owner, self._depth = None, 0
621
+ self._cond.notify_all()
622
+ mine = True
623
+ elif record is not None:
624
+ record[0] += 1
625
+ mine = True
626
+ else:
627
+ mine = False
628
+ try:
629
+ yield
630
+ finally:
631
+ if mine:
632
+ with self._cond:
633
+ record = self._parked[me]
634
+ record[0] -= 1
635
+ if record[0] == 0:
636
+ while self._owner is not None:
637
+ self._cond.wait()
638
+ del self._parked[me]
639
+ self._owner, self._depth = me, record[1]
640
+
641
+
642
+ class ThreadRoutedStream(io.TextIOBase):
643
+ """sys.stdout / sys.stderr: each writer thread's text goes to the buffer it registered, else the cell's."""
644
+
645
+ def __init__(self, stderr: bool) -> None:
646
+ self._stderr = stderr
647
+ self._targets: dict[int, io.StringIO] = {}
648
+ # Followed by the parallel()/pipeline() workers a capturing thread starts (they run in a copy of its context).
649
+ self._inherited: contextvars.ContextVar[io.StringIO | None] = contextvars.ContextVar(
650
+ f"senpi_output_{'stderr' if stderr else 'stdout'}", default=None
651
+ )
652
+ self.cell_target: io.StringIO | None = None
653
+
654
+ @contextlib.contextmanager
655
+ def capture(self, buffer: io.StringIO, *, cell: bool) -> Iterator[None]:
656
+ me = threading.get_ident()
657
+ previous = self._targets.get(me)
658
+ self._targets[me] = buffer
659
+ inherited = self._inherited.set(buffer)
660
+ if cell:
661
+ self.cell_target = buffer
662
+ try:
663
+ yield
664
+ finally:
665
+ self._inherited.reset(inherited)
666
+ if previous is None:
667
+ self._targets.pop(me, None)
668
+ else:
669
+ self._targets[me] = previous
670
+ if cell:
671
+ self.cell_target = None
672
+
673
+ def write(self, text: str) -> int:
674
+ target = self._targets.get(threading.get_ident()) or self._inherited.get() or self.cell_target
675
+ if target is None:
676
+ return sys.__stderr__.write(text)
677
+ return target.write(text)
678
+
679
+ def writable(self) -> bool:
680
+ return True
681
+
682
+ @property
683
+ def encoding(self) -> None:
684
+ return None
685
+
686
+
687
+ @dataclass
688
+ class _Entry:
689
+ name: str
690
+ fn: Callable[..., Any]
691
+ description: str
692
+ input_schema: dict[str, Any]
693
+ params: list[str]
694
+ revision: int
695
+
696
+
697
+ class Registry:
698
+ def __init__(self, on_change: Callable[[list[str]], None]) -> None:
699
+ self._lock = threading.Lock()
700
+ self._entries: dict[str, _Entry] = {}
701
+ # Revisions outlive undefine: a redefined name never reuses a revision an old descriptor carries.
702
+ self._revisions: dict[str, int] = {}
703
+ self._on_change = on_change
704
+ self.generation = 1
705
+
706
+ def define(self, fn: Any, *, name: Any = None, description: Any = None, schema: Any = None) -> Any:
707
+ inferred = infer_tool(fn, name=name, description=description, schema=schema)
708
+ key = tool_key(inferred["name"])
709
+ with self._lock:
710
+ existing = self._entries.get(key)
711
+ if existing is not None and existing.name != inferred["name"]:
712
+ raise KernelToolError("tool_name_collision", f"Kernel tool name collides: {inferred['name']}")
713
+ revision = self._revisions.get(key, 0) + 1
714
+ self._revisions[key] = revision
715
+ self._entries[key] = _Entry(fn=fn, revision=revision, **inferred)
716
+ names = sorted(entry.name for entry in self._entries.values())
717
+ self._on_change(names)
718
+ return fn
719
+
720
+ def undefine(self, name: Any) -> bool:
721
+ if not isinstance(name, str):
722
+ return False
723
+ with self._lock:
724
+ removed = self._entries.pop(tool_key(name), None) is not None
725
+ names = sorted(entry.name for entry in self._entries.values())
726
+ if removed:
727
+ self._on_change(names)
728
+ return removed
729
+
730
+ def defined(self) -> list[str]:
731
+ with self._lock:
732
+ return sorted(entry.name for entry in self._entries.values())
733
+
734
+ def describe(self, names: list[str]) -> list[dict[str, Any]]:
735
+ results = []
736
+ with self._lock:
737
+ for name in names:
738
+ entry = self._entries.get(tool_key(name))
739
+ if entry is None:
740
+ error = {"code": "kernel_tool_missing", "message": f"Kernel tool is not defined: {name}"}
741
+ results.append({"name": name, "ok": False, "error": error})
742
+ continue
743
+ descriptor = {"name": entry.name, "description": entry.description, "input_schema": entry.input_schema,
744
+ "language": "py", "kernel_generation": self.generation, "definition_revision": entry.revision}
745
+ results.append({"name": name, "ok": True, "descriptor": descriptor})
746
+ return results
747
+
748
+ def resolve(self, request: dict[str, Any]) -> _Entry:
749
+ if request.get("kernel_generation") != self.generation:
750
+ raise KernelToolError("kernel_tool_stale", "Kernel tool descriptor generation is stale")
751
+ with self._lock:
752
+ entry = self._entries.get(tool_key(str(request.get("name", ""))))
753
+ if entry is None:
754
+ raise KernelToolError("kernel_tool_missing", f"Kernel tool is not defined: {request.get('name')}")
755
+ if entry.revision != request.get("definition_revision"):
756
+ raise KernelToolError("kernel_tool_stale", "Kernel tool descriptor revision is stale")
757
+ return entry
758
+
759
+ def is_live(self, entry: _Entry) -> bool:
760
+ with self._lock:
761
+ return self._entries.get(tool_key(entry.name)) is entry
762
+
763
+
764
+ class Cancelled(BaseException):
765
+ pass
766
+
767
+
768
+ @dataclass
769
+ class Invocation:
770
+ request_id: str
771
+ call_id: str
772
+ scope: Any
773
+ cancelled: threading.Event = field(default_factory=threading.Event)
774
+ task: tuple[asyncio.AbstractEventLoop, asyncio.Task[Any]] | None = None
775
+
776
+
777
+ CURRENT: contextvars.ContextVar[Invocation | None] = contextvars.ContextVar("senpi_kernel_tool_call", default=None)
778
+
779
+
780
+ def host_call_refusal(scope: Any, tool_name: str) -> str | None:
781
+ tools = scope.get("tools") if isinstance(scope, dict) else None
782
+ if not isinstance(tools, dict):
783
+ return None
784
+ deny, allow = tools.get("deny"), tools.get("allow")
785
+ if deny is not None and (not isinstance(deny, list) or tool_name in deny):
786
+ return "deny"
787
+ if allow is not None and (not isinstance(allow, list) or tool_name not in allow):
788
+ return "allow"
789
+ return None
790
+
791
+
792
+ def jsonable(value: Any) -> Any:
793
+ try:
794
+ json.dumps(value, allow_nan=False)
795
+ except (TypeError, ValueError):
796
+ return repr(value)
797
+ return value
798
+
799
+
800
+ def validate_args(schema: dict[str, Any], args: Any) -> dict[str, Any]:
801
+ if not isinstance(args, dict):
802
+ raise KernelToolError("invalid_tool_definition", "kernel tool args must be a JSON object")
803
+ missing = [key for key in schema.get("required", []) if key not in args]
804
+ if missing:
805
+ raise KernelToolError("invalid_tool_definition", f"kernel tool args are missing: {', '.join(missing)}")
806
+ properties = schema.get("properties", {})
807
+ unknown = [key for key in args if key not in properties]
808
+ if unknown:
809
+ raise KernelToolError("invalid_tool_definition", f"kernel tool args are not parameters: {', '.join(unknown)}")
810
+ return args
811
+
812
+
813
+ def is_coroutine_function(fn: Callable[..., Any]) -> bool:
814
+ return inspect.iscoroutinefunction(fn)
815
+
816
+
817
+ CANCELLED_AT_PARK_POINT = "cancelled: took effect at a host park point"
818
+ CANCELLED_AT_AWAIT = "cancelled: took effect at an await point"
819
+ CANCELLED_BEFORE_START = "cancelled: the call never started"
820
+ CANCELLED_RESULT_DROPPED = "cancelled: the call finished after cancellation; its result was dropped"
821
+
822
+
823
+ class KernelToolRunner:
824
+ """Answers describe from the reader thread and runs each invocation on its own thread under the token."""
825
+
826
+ def __init__(
827
+ self,
828
+ emit: Callable[[dict[str, Any]], None],
829
+ token: OwnerToken,
830
+ registry: Registry,
831
+ streams: tuple[ThreadRoutedStream, ThreadRoutedStream],
832
+ ) -> None:
833
+ self._emit = emit
834
+ self._token = token
835
+ self._registry = registry
836
+ self._streams = streams
837
+ self._lock = threading.Lock()
838
+ self._invocations: dict[str, Invocation] = {}
839
+ self._loop: asyncio.AbstractEventLoop | None = None
840
+ self._closed = False
841
+
842
+ def handle(self, message: dict[str, Any]) -> bool:
843
+ kind = message.get("type")
844
+ if kind == "kernel-tool-describe":
845
+ names = [str(name) for name in message.get("names", [])]
846
+ self._emit({"type": "kernel-tool-describe-reply", "requestId": message.get("requestId"), "ok": True,
847
+ "results": self._registry.describe(names)})
848
+ return True
849
+ if kind == "kernel-tool-invoke":
850
+ self._start(message)
851
+ return True
852
+ if kind == "kernel-tool-cancel":
853
+ self._cancel(str(message.get("requestId", "")))
854
+ return True
855
+ return False
856
+
857
+ def close(self) -> None:
858
+ with self._lock:
859
+ self._closed = True
860
+ pending = list(self._invocations.values())
861
+ for invocation in pending:
862
+ invocation.cancelled.set()
863
+
864
+ def _start(self, message: dict[str, Any]) -> None:
865
+ invocation = Invocation(request_id=str(message.get("requestId", "")), call_id=str(message.get("call_id", "")),
866
+ scope=message.get("scope"))
867
+ with self._lock:
868
+ if self._closed:
869
+ self._reply_error(invocation, "tools_unavailable", "Python kernel is closing")
870
+ return
871
+ self._invocations[invocation.request_id] = invocation
872
+ thread = threading.Thread(target=self._run, args=(invocation, message), name="senpi-kernel-tool", daemon=True)
873
+ thread.start()
874
+
875
+ def _cancel(self, request_id: str) -> None:
876
+ with self._lock:
877
+ invocation = self._invocations.get(request_id)
878
+ if invocation is None:
879
+ return
880
+ invocation.cancelled.set()
881
+ if invocation.task is not None:
882
+ loop, task = invocation.task
883
+ loop.call_soon_threadsafe(task.cancel)
884
+
885
+ def _run(self, invocation: Invocation, message: dict[str, Any]) -> None:
886
+ output = io.StringIO()
887
+ try:
888
+ entry = self._registry.resolve(message)
889
+ args = validate_args(entry.input_schema, message.get("args"))
890
+ self._token.acquire()
891
+ try:
892
+ if invocation.cancelled.is_set():
893
+ raise Cancelled(CANCELLED_BEFORE_START)
894
+ CURRENT.set(invocation)
895
+ stdout, stderr = self._streams
896
+ with stdout.capture(output, cell=False), stderr.capture(output, cell=False):
897
+ value = self._call(invocation, entry.fn, args)
898
+ finally:
899
+ CURRENT.set(None)
900
+ self._token.release()
901
+ if invocation.cancelled.is_set():
902
+ raise Cancelled(CANCELLED_RESULT_DROPPED)
903
+ if not self._registry.is_live(entry):
904
+ raise KernelToolError("kernel_tool_stale", "Kernel tool descriptor is stale")
905
+ self._emit({"type": "kernel-tool-invoke-reply", "requestId": invocation.request_id, "ok": True,
906
+ "value": jsonable(value), **self._output_field(output)})
907
+ except Cancelled as exc:
908
+ self._reply_error(invocation, "kernel_tool_cancelled", str(exc), output)
909
+ except KernelToolError as exc:
910
+ self._reply_error(invocation, exc.code, str(exc), output, exc.details)
911
+ except BaseException as exc: # noqa: BROAD_EXCEPT_OK — the invocation boundary serializes the tool's own failure.
912
+ self._reply_error(invocation, "kernel_tool_failed", f"{type(exc).__name__}: {exc}", output,
913
+ stack="".join(traceback.format_exception(type(exc), exc, exc.__traceback__)))
914
+ finally:
915
+ with self._lock:
916
+ self._invocations.pop(invocation.request_id, None)
917
+
918
+ def _call(self, invocation: Invocation, fn: Callable[..., Any], args: dict[str, Any]) -> Any:
919
+ result = fn(**args)
920
+ if not asyncio.iscoroutine(result) and not isinstance(result, asyncio.Future):
921
+ return result
922
+ loop = self._callback_loop()
923
+ temporary = loop.is_running()
924
+ if temporary:
925
+ loop = asyncio.new_event_loop()
926
+ asyncio.set_event_loop(loop)
927
+ task = loop.create_task(_awaited(result))
928
+ invocation.task = (loop, task)
929
+ try:
930
+ return loop.run_until_complete(task)
931
+ except asyncio.CancelledError as exc:
932
+ raise Cancelled(CANCELLED_AT_AWAIT) from exc
933
+ except RuntimeError as exc:
934
+ if "attached to a different loop" in str(exc) or "different event loop" in str(exc):
935
+ raise KernelToolError(
936
+ "kernel_tool_loop_mismatch",
937
+ "an awaited object belongs to another event loop (for example the parent cell's); "
938
+ "create it inside the tool",
939
+ ) from exc
940
+ raise
941
+ finally:
942
+ invocation.task = None
943
+ asyncio.set_event_loop(None)
944
+ if temporary:
945
+ loop.close()
946
+
947
+ def _callback_loop(self) -> asyncio.AbstractEventLoop:
948
+ with self._lock:
949
+ if self._loop is None or self._loop.is_closed():
950
+ self._loop = asyncio.new_event_loop()
951
+ return self._loop
952
+
953
+ def _output_field(self, output: io.StringIO) -> dict[str, Any]:
954
+ text = output.getvalue()
955
+ return {"output": text} if text else {}
956
+
957
+ def _reply_error(self, invocation: Invocation, code: str, message: str, output: io.StringIO | None = None,
958
+ details: Any = None, stack: str | None = None) -> None:
959
+ error: dict[str, Any] = {"code": code, "message": message}
960
+ if details is not None:
961
+ error["details"] = details
962
+ if stack is not None:
963
+ error["stack"] = stack
964
+ frame: dict[str, Any] = {"type": "kernel-tool-invoke-reply", "requestId": invocation.request_id, "ok": False,
965
+ "error": error}
966
+ if output is not None:
967
+ frame.update(self._output_field(output))
968
+ self._emit(frame)
969
+
970
+
971
+ async def _awaited(awaitable: Any) -> Any:
972
+ return await awaitable
973
+
974
+
975
+ KERNEL_TOOL_TOKEN = OwnerToken()
976
+ KERNEL_TOOL_STREAMS = (ThreadRoutedStream(stderr=False), ThreadRoutedStream(stderr=True))
977
+ KERNEL_TOOL_REGISTRY = Registry(lambda names: emit({"type": "kernel-tools-defined", "names": names}))
978
+ KERNEL_TOOL_RUNNER = KernelToolRunner(emit, KERNEL_TOOL_TOKEN, KERNEL_TOOL_REGISTRY, KERNEL_TOOL_STREAMS)
979
+
980
+
981
+ def _prelude_tool_error(exc: KernelToolError) -> PreludeRuntimeError:
982
+ return PreludeRuntimeError(f"{exc.code}: {exc}", exc.code)
983
+
984
+
985
+ def _guard_host_call(payload: dict[str, Any]) -> None:
986
+ invocation = CURRENT.get()
987
+ if invocation is None:
988
+ return
989
+ if invocation.cancelled.is_set():
990
+ raise Cancelled(CANCELLED_AT_PARK_POINT)
991
+ tool_name = payload.get("toolName")
992
+ refusal = host_call_refusal(invocation.scope, tool_name) if isinstance(tool_name, str) else None
993
+ if refusal is not None:
994
+ raise KernelToolError(
995
+ "kernel_tool_host_denied",
996
+ f"Host tool is outside this kernel tool call's scope: {tool_name} ({refusal})",
997
+ {"tool": tool_name, "call_id": invocation.call_id, "reason": refusal},
998
+ )
999
+
1000
+
396
1001
  class ToolCallable:
397
1002
  __slots__ = ("_name",)
398
1003
 
@@ -421,6 +1026,21 @@ class ToolCallable:
421
1026
  class ToolProxy:
422
1027
  __slots__ = ()
423
1028
 
1029
+ def __call__(self, fn: Any = None, /, *, name: Any = None, description: Any = None, schema: Any = None) -> Any:
1030
+ def register(target: Any) -> Any:
1031
+ try:
1032
+ return KERNEL_TOOL_REGISTRY.define(target, name=name, description=description, schema=schema)
1033
+ except KernelToolError as exc:
1034
+ raise _prelude_tool_error(exc) from None
1035
+
1036
+ return register if fn is None else register(fn)
1037
+
1038
+ def defined(self) -> list[str]:
1039
+ return KERNEL_TOOL_REGISTRY.defined()
1040
+
1041
+ def undefine(self, name: Any) -> bool:
1042
+ return KERNEL_TOOL_REGISTRY.undefine(name)
1043
+
424
1044
  def __getattr__(self, name: str) -> ToolCallable:
425
1045
  if name.startswith("_"):
426
1046
  raise AttributeError(name)
@@ -500,6 +1120,8 @@ def completion(
500
1120
  if schema is not None:
501
1121
  options["schema"] = schema
502
1122
  response = bridge_post("/completion", {"prompt": prompt, "opts": options})
1123
+ if options.get("handle") is True:
1124
+ return handle(response)
503
1125
  if not isinstance(response, dict):
504
1126
  return response
505
1127
  if "value" in response:
@@ -616,6 +1238,129 @@ def agent(
616
1238
  return node
617
1239
 
618
1240
 
1241
+ _HANDLE_KINDS = ("agent", "completion", "workpool")
1242
+ _WAIT_MODES = ("all", "any", "settled")
1243
+ _HANDLE_USAGE = (
1244
+ "handle() expects an agent(..., handle=True) record, a workpool, a completion handle, "
1245
+ "or a saved {kind, id, run_epoch} reference"
1246
+ )
1247
+
1248
+
1249
+ def _handle_ref(value: Any) -> dict[str, Any]:
1250
+ if isinstance(value, HandleView):
1251
+ return dict(value.ref)
1252
+ if isinstance(value, Workpool):
1253
+ return {"kind": "workpool", "id": value.pool_id, "run_epoch": 0}
1254
+ if not isinstance(value, dict):
1255
+ raise PreludeTypeError(_HANDLE_USAGE)
1256
+ if isinstance(value.get("pool_id"), str):
1257
+ return {"kind": "workpool", "id": value["pool_id"], "run_epoch": 0}
1258
+ kind = value.get("kind")
1259
+ if kind not in _HANDLE_KINDS:
1260
+ handle_uri = value.get("handle")
1261
+ scheme = handle_uri.split("://", 1)[0] if isinstance(handle_uri, str) else None
1262
+ kind = scheme if scheme in _HANDLE_KINDS else None
1263
+ identity = value.get("id", value.get("task_id"))
1264
+ if kind is None or not isinstance(identity, str) or not identity:
1265
+ raise PreludeTypeError(_HANDLE_USAGE)
1266
+ run_epoch = value.get("run_epoch", 0 if kind == "workpool" else None)
1267
+ if isinstance(run_epoch, bool) or not isinstance(run_epoch, int) or run_epoch < 0:
1268
+ raise PreludeTypeError(f"{_HANDLE_USAGE}; run_epoch must be a non-negative integer")
1269
+ return {"kind": kind, "id": identity, "run_epoch": run_epoch}
1270
+
1271
+
1272
+ def _handle_call(tool_name: str, args: dict[str, Any]) -> Any:
1273
+ return bridge_post("/call", {"callId": f"py-{uuid.uuid4()}", "toolName": tool_name, "args": args})
1274
+
1275
+
1276
+ def _bridge_wait_post(args: dict[str, Any], timeout: float | None) -> Any:
1277
+ # A long-lived request: no 60 s socket cap. An explicit timeout bounds it (plus grace for the host's
1278
+ # reply); without one only the cell's own end does: SIGINT from the host (cancel, the cell's hard limit,
1279
+ # which the eval timeout can raise) interrupts the blocking read, which closes the socket and so the
1280
+ # host-side subscription.
1281
+ socket_timeout = None if timeout is None else float(timeout) + _WAIT_SOCKET_GRACE_SECONDS
1282
+ return bridge_post(
1283
+ "/call",
1284
+ {"callId": f"py-{uuid.uuid4()}", "toolName": RESERVED_WAIT_TOOL, "args": args},
1285
+ socket_timeout=socket_timeout,
1286
+ )
1287
+
1288
+
1289
+ def wait(handles: Any, *, timeout: float | None = None, mode: str = "all") -> Any:
1290
+ """Block until the handles settle; never cancels work. See tool_schema("eval:wait")."""
1291
+ if handles is None:
1292
+ items: list[Any] = []
1293
+ elif isinstance(handles, (list, tuple)):
1294
+ items = list(handles)
1295
+ else:
1296
+ items = [handles]
1297
+ if timeout is not None and (
1298
+ isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not math.isfinite(timeout) or timeout < 0
1299
+ ):
1300
+ raise PreludeValueError("wait() timeout must be a finite number of seconds >= 0")
1301
+ if mode not in _WAIT_MODES:
1302
+ raise PreludeValueError("wait() mode must be 'all', 'any' or 'settled'")
1303
+ args: dict[str, Any] = {"refs": [_handle_ref(item) for item in items], "mode": mode}
1304
+ if timeout is not None:
1305
+ args["timeout"] = timeout
1306
+ return _bridge_wait_post(args, timeout)
1307
+
1308
+
1309
+ class HandleControl:
1310
+ """Epoch-fenced control over one handle; every call goes through the host capability."""
1311
+
1312
+ __slots__ = ("_ref",)
1313
+
1314
+ def __init__(self, ref: dict[str, Any]) -> None:
1315
+ self._ref = dict(ref)
1316
+
1317
+ def __repr__(self) -> str:
1318
+ return f"<handle.control {self._ref['kind']}://{self._ref['id']}@{self._ref['run_epoch']}>"
1319
+
1320
+ def status(self) -> Any:
1321
+ return _handle_call(RESERVED_HANDLE_STATUS_TOOL, {"ref": self._ref})
1322
+
1323
+ def output(self, format: str = "raw", offset: int | None = None, limit: int | None = None) -> Any:
1324
+ args: dict[str, Any] = {"ref": self._ref, "format": format}
1325
+ if offset is not None:
1326
+ args["offset"] = offset
1327
+ if limit is not None:
1328
+ args["limit"] = limit
1329
+ return _handle_call(RESERVED_HANDLE_OUTPUT_TOOL, args)
1330
+
1331
+ def send(self, message: str) -> Any:
1332
+ return _handle_call(RESERVED_HANDLE_SEND_TOOL, {"ref": self._ref, "message": str(message)})
1333
+
1334
+ def cancel(self) -> Any:
1335
+ return _handle_call(RESERVED_HANDLE_CANCEL_TOOL, {"ref": self._ref})
1336
+
1337
+ def wait(self, timeout: float | None = None) -> Any:
1338
+ return wait([self._ref], timeout=timeout, mode="all")[0]
1339
+
1340
+
1341
+ class HandleView(dict):
1342
+ """The legacy record's fields as a dict, plus `control` and `ref` as attributes (never keys)."""
1343
+
1344
+ __slots__ = ("control", "ref")
1345
+
1346
+ def __init__(self, fields: dict[str, Any], ref: dict[str, Any]) -> None:
1347
+ super().__init__(fields)
1348
+ self.ref = dict(ref)
1349
+ self.control = HandleControl(ref)
1350
+
1351
+
1352
+ def handle(value: Any) -> HandleView:
1353
+ """Rich view of an agent record, workpool, completion handle or saved reference; see tool_schema("eval:helpers")."""
1354
+ ref = _handle_ref(value)
1355
+ fields: dict[str, Any] = {}
1356
+ if isinstance(value, dict):
1357
+ fields.update({key: item for key, item in value.items() if not callable(item)})
1358
+ fields.setdefault("id", ref["id"])
1359
+ fields.setdefault("run_epoch", ref["run_epoch"])
1360
+ fields.setdefault("handle", f"{ref['kind']}://{ref['id']}")
1361
+ return HandleView(fields, ref)
1362
+
1363
+
619
1364
  def _pool_map(items: Iterable[Any], function: Callable[[Any], Any]) -> list[Any]:
620
1365
  values = list(items)
621
1366
  if not values:
@@ -629,8 +1374,17 @@ def _pool_map(items: Iterable[Any], function: Callable[[Any], Any]) -> list[Any]
629
1374
  workers = min(max(1, width), len(values))
630
1375
  results: list[Any] = [None] * len(values)
631
1376
  errors: dict[int, BaseException] = {}
1377
+ group = _token_key()
1378
+
1379
+ def submit(pool: ThreadPoolExecutor, value: Any) -> Any:
1380
+ # Each worker runs in a copy of this thread's context: the kernel tool call it serves (scope),
1381
+ # its output buffer, and its token group, so its host calls park this thread's token.
1382
+ context = contextvars.copy_context()
1383
+ context.run(TOKEN_GROUP.set, group)
1384
+ return pool.submit(context.run, function, value)
1385
+
632
1386
  with ThreadPoolExecutor(max_workers=workers) as pool:
633
- futures = {pool.submit(function, value): index for index, value in enumerate(values)}
1387
+ futures = {submit(pool, value): index for index, value in enumerate(values)}
634
1388
  for future in as_completed(futures):
635
1389
  index = futures[future]
636
1390
  try:
@@ -997,6 +1751,8 @@ USER_NS.update(
997
1751
  "workpool": workpool,
998
1752
  "output": output,
999
1753
  "tool_schema": tool_schema,
1754
+ "wait": wait,
1755
+ "handle": handle,
1000
1756
  "__senpi_magic": _magic,
1001
1757
  "__senpi_magic_cell": _magic_cell,
1002
1758
  "__senpi_shell": _shell,
@@ -1364,7 +2120,9 @@ def run_cell(cell_id: str, code: str, preludes: Any = None) -> None:
1364
2120
  signal.signal(signal.SIGINT, signal.default_int_handler)
1365
2121
  result: dict[str, Any]
1366
2122
  try:
1367
- with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
2123
+ KERNEL_TOOL_TOKEN.acquire()
2124
+ cell_stdout, cell_stderr = KERNEL_TOOL_STREAMS
2125
+ with cell_stdout.capture(stdout, cell=True), cell_stderr.capture(stderr, cell=True):
1368
2126
  apply_preludes(preludes)
1369
2127
  body, expression = compile_cell(code)
1370
2128
  LOOP.run_until_complete(run_code(body, False))
@@ -1390,6 +2148,7 @@ def run_cell(cell_id: str, code: str, preludes: Any = None) -> None:
1390
2148
  "durationMs": elapsed(start),
1391
2149
  }
1392
2150
  finally:
2151
+ KERNEL_TOOL_TOKEN.release()
1393
2152
  signal.signal(signal.SIGINT, signal.SIG_IGN)
1394
2153
  try:
1395
2154
  memory = KERNEL_MEMORY.after_cell()
@@ -1405,7 +2164,7 @@ def elapsed(start: float) -> int:
1405
2164
  return max(0, int((time.monotonic() - start) * 1000))
1406
2165
 
1407
2166
 
1408
- def handle(message: dict[str, Any]) -> bool:
2167
+ def _handle_message(message: dict[str, Any]) -> bool:
1409
2168
  global CONNECTION
1410
2169
  message_type = message.get("type")
1411
2170
  if message_type == "init":
@@ -1414,6 +2173,9 @@ def handle(message: dict[str, Any]) -> bool:
1414
2173
  emit({"type": "init-failed", "error": {"message": "missing bridge connection"}})
1415
2174
  return True
1416
2175
  CONNECTION = connection
2176
+ generation = message.get("kernelGeneration")
2177
+ if isinstance(generation, int) and not isinstance(generation, bool):
2178
+ KERNEL_TOOL_REGISTRY.generation = generation
1417
2179
  KERNEL_MEMORY.configure(message.get("memory"))
1418
2180
  emit({"type": "ready"})
1419
2181
  return True
@@ -1421,6 +2183,7 @@ def handle(message: dict[str, Any]) -> bool:
1421
2183
  run_cell(str(message.get("cellId", "")), str(message.get("code", "")), message.get("preludes"))
1422
2184
  return True
1423
2185
  if message_type == "close":
2186
+ KERNEL_TOOL_RUNNER.close()
1424
2187
  emit({"type": "closed"})
1425
2188
  return False
1426
2189
  return True
@@ -1476,14 +2239,37 @@ def _start_parent_watch() -> None:
1476
2239
  ).start()
1477
2240
 
1478
2241
 
2242
+ def _read_control(commands: queue.Queue[tuple[str, Any]]) -> None:
2243
+ # Kernel-tool frames are served here, so a callback can be admitted while the main thread is inside a cell.
2244
+ for raw in sys.stdin:
2245
+ try:
2246
+ message = json.loads(raw)
2247
+ except BaseException as exc: # noqa: BROAD_EXCEPT_OK — malformed input is reported by the main loop, in order.
2248
+ commands.put(("malformed", exc))
2249
+ continue
2250
+ if isinstance(message, dict) and KERNEL_TOOL_RUNNER.handle(message):
2251
+ continue
2252
+ commands.put(("message", message))
2253
+ commands.put(("eof", None))
2254
+
2255
+
1479
2256
  def main() -> None:
1480
2257
  emit_status("kernel-startup", force=True, stage="host-init")
1481
2258
  signal.signal(signal.SIGINT, signal.SIG_IGN)
1482
2259
  _start_parent_watch()
2260
+ sys.stdout, sys.stderr = KERNEL_TOOL_STREAMS
2261
+ commands: queue.Queue[tuple[str, Any]] = queue.Queue()
2262
+ threading.Thread(target=_read_control, args=(commands,), name="senpi-control-reader", daemon=True).start()
1483
2263
  host_closed = False
1484
- for raw in sys.stdin:
2264
+ while True:
2265
+ kind, payload = commands.get()
2266
+ if kind == "eof":
2267
+ break
2268
+ if kind == "malformed":
2269
+ emit({"type": "init-failed", "error": bridge_error(payload)})
2270
+ continue
1485
2271
  try:
1486
- if not handle(json.loads(raw)):
2272
+ if not _handle_message(payload):
1487
2273
  host_closed = True
1488
2274
  break
1489
2275
  except BaseException as exc: # noqa: BROAD_EXCEPT_OK — process boundary serializes malformed input and interrupts.