@code-yeongyu/senpi-codemode 2026.10.5 → 2026.10.7

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 (68) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +20 -2
  3. package/package.json +4 -4
  4. package/src/bridge/kernel-tools-protocol.ts +1 -1
  5. package/src/bridge/reserved.ts +10 -0
  6. package/src/bridges/eval-virtual-schemas.ts +79 -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/config/feature-settings.ts +110 -0
  13. package/src/config/memory-settings.ts +8 -0
  14. package/src/config/settings-constants.ts +7 -0
  15. package/src/config/settings-overrides.ts +72 -0
  16. package/src/config/settings.ts +35 -78
  17. package/src/config/unknown-settings.ts +19 -0
  18. package/src/extension/bridge-dispatch.ts +41 -0
  19. package/src/extension/bridge-tool-call.ts +41 -0
  20. package/src/extension/host-facts.ts +18 -0
  21. package/src/extension/idle-parking-kernel.ts +272 -0
  22. package/src/extension/kernel-registration.ts +25 -0
  23. package/src/extension/kernel-tools-probe.ts +24 -0
  24. package/src/extension/removed-tool-hints.ts +15 -0
  25. package/src/extension/runtime-factory.ts +3 -0
  26. package/src/extension/session-manager-contract.ts +3 -0
  27. package/src/extension/session-manager.ts +58 -58
  28. package/src/extension/start-recovery.ts +111 -0
  29. package/src/handles/completion-handles.ts +202 -0
  30. package/src/handles/handle-args.ts +103 -0
  31. package/src/handles/handle-registry.ts +164 -0
  32. package/src/handles/wait.ts +214 -0
  33. package/src/handles/watch-queue.ts +102 -0
  34. package/src/index.ts +99 -94
  35. package/src/kernels/jl/handles.jl +124 -0
  36. package/src/kernels/jl/kernel.ts +3 -1
  37. package/src/kernels/jl/prelude.jl +9 -0
  38. package/src/kernels/jl/runner.jl +160 -1
  39. package/src/kernels/js/context-manager.ts +1 -1
  40. package/src/kernels/js/handles.js +93 -0
  41. package/src/kernels/js/kernel-tools-define.d.ts +6 -0
  42. package/src/kernels/js/kernel-tools-define.js +3 -1
  43. package/src/kernels/js/kernel-tools-registry.d.ts +2 -0
  44. package/src/kernels/js/kernel-tools-registry.js +18 -9
  45. package/src/kernels/js/kernel-tools-schema.js +6 -2
  46. package/src/kernels/js/kernel-tools-types.ts +2 -1
  47. package/src/kernels/js/worker-global-sizes.js +84 -14
  48. package/src/kernels/js/worker-runtime.js +10 -1
  49. package/src/kernels/py/host-cell.ts +42 -0
  50. package/src/kernels/py/kernel-contract.ts +2 -0
  51. package/src/kernels/py/kernel.ts +48 -4
  52. package/src/kernels/py/prelude.py +145 -4
  53. package/src/kernels/rb/handles.rb +97 -0
  54. package/src/kernels/rb/kernel.ts +3 -1
  55. package/src/kernels/rb/paths.rb +37 -0
  56. package/src/kernels/rb/prelude.rb +15 -38
  57. package/src/kernels/rb/runner.rb +141 -0
  58. package/src/kernels/shared/kernel-memory-host.ts +14 -6
  59. package/src/kernels/{js/kernel-tools-host.ts → shared/kernel-tools-pump.ts} +2 -2
  60. package/src/kernels/shared/process-group-cpu.ts +139 -0
  61. package/src/kernels/shared/subprocess-contract.ts +3 -0
  62. package/src/kernels/shared/subprocess-kernel.ts +33 -1
  63. package/src/kernels/shared/subprocess-startup.ts +90 -0
  64. package/src/tool/cell-handler.ts +21 -18
  65. package/src/tool/cell-reserved-dispatch.ts +51 -0
  66. package/src/tool/eval-tool-options.ts +3 -0
  67. package/src/tool/run-eval-cell.ts +7 -6
  68. package/src/tool/types.ts +15 -0
@@ -0,0 +1,42 @@
1
+ import type { KernelToHostMessage } from "../../bridge/protocol.ts";
2
+ import type { HostCellExecutor } from "../../tool/types.ts";
3
+
4
+ type HostCellOutcome = Awaited<ReturnType<HostCellExecutor>>;
5
+
6
+ import type { PendingRun, ResultMessage } from "./kernel-contract.ts";
7
+ import { failedPythonResult } from "./transport.ts";
8
+
9
+ export function runHostCell(
10
+ pending: PendingRun,
11
+ host: HostCellExecutor,
12
+ io: { readonly emit: (message: KernelToHostMessage) => void; readonly settle: (result: ResultMessage) => void },
13
+ ): void {
14
+ const abort = new AbortController();
15
+ pending.hostAbort = abort;
16
+ const cellId = pending.input.cellId;
17
+ let finished = false;
18
+ // Output from an executor that was aborted, or after its outcome, belongs to no cell.
19
+ const emit = (message: KernelToHostMessage): void => {
20
+ if (!finished && !abort.signal.aborted) io.emit(message);
21
+ };
22
+ // Calling the executor inside a promise keeps a synchronous throw in this cell instead of failing the queue.
23
+ const done = new Promise<HostCellOutcome>((resolve) => resolve(host({ signal: abort.signal, emit }))).then(
24
+ (outcome): ResultMessage =>
25
+ outcome.ok
26
+ ? {
27
+ type: "result",
28
+ cellId,
29
+ ok: true,
30
+ durationMs: 0,
31
+ ...(outcome.valueRepr === undefined ? {} : { valueRepr: outcome.valueRepr }),
32
+ }
33
+ : { type: "result", cellId, ok: false, error: outcome.error, durationMs: 0 },
34
+ (error: unknown): ResultMessage =>
35
+ failedPythonResult(cellId, error instanceof Error ? error.message : String(error)),
36
+ );
37
+ pending.hostDone = done;
38
+ void done.then((result) => {
39
+ finished = true;
40
+ if (!abort.signal.aborted) io.settle(result);
41
+ });
42
+ }
@@ -39,4 +39,6 @@ export interface PendingRun {
39
39
  interruptReason?: string;
40
40
  /** Set while an interrupt outcome is pending; resolved once the kernel knows whether state survived. */
41
41
  resolveStateRetained?: (retained: boolean) => void;
42
+ hostAbort?: AbortController;
43
+ hostDone?: Promise<ResultMessage>;
42
44
  }
@@ -5,6 +5,7 @@ import { rejectKernelToolsUnavailable } from "../kernel-tools-unavailable.ts";
5
5
  import { describeExit } from "../shared/kernel-death.ts";
6
6
  import { KernelMemoryHost } from "../shared/kernel-memory-host.ts";
7
7
  import { KernelPreludeTracker } from "../shared/kernel-prelude-plan.ts";
8
+ import { runHostCell } from "./host-cell.ts";
8
9
  import type { PendingRun, PythonKernelRunOptions, PythonKernelStartOptions, ResultMessage } from "./kernel-contract.ts";
9
10
  import { pythonStartupHangGuardMs } from "./startup.ts";
10
11
  import { failedPythonResult, PythonKernelTransport } from "./transport.ts";
@@ -104,6 +105,11 @@ export class PythonKernel {
104
105
  }
105
106
  }
106
107
  const active = this.#active;
108
+ if (active?.hostAbort && active.interruptReason === undefined) {
109
+ active.interruptReason = reason;
110
+ void this.#stopHostEntry(active, failedPythonResult(active.input.cellId, "Eval interrupted"));
111
+ return { stateRetained: Promise.resolve(true) };
112
+ }
107
113
  const transport = this.#transport;
108
114
  if (!active || !transport || active.interruptReason !== undefined)
109
115
  return { stateRetained: Promise.resolve(true) };
@@ -201,6 +207,14 @@ export class PythonKernel {
201
207
  const timeoutMs = pending.input.timeoutMs;
202
208
  if (timeoutMs !== undefined)
203
209
  pending.timeoutTimer = setTimeout(() => this.#timeoutRun(pending, timeoutMs), timeoutMs);
210
+ const host = pending.input.host;
211
+ if (host !== undefined) {
212
+ runHostCell(pending, host, {
213
+ emit: (message) => (pending.input.onMessage ?? this.#options.onMessage)?.(message),
214
+ settle: (result) => this.#settleRun(pending, result),
215
+ });
216
+ return;
217
+ }
204
218
  try {
205
219
  this.#transport?.run({
206
220
  ...pending.input,
@@ -219,11 +233,40 @@ export class PythonKernel {
219
233
 
220
234
  #timeoutRun(pending: PendingRun, timeoutMs: number): void {
221
235
  if (this.#active !== pending) return;
236
+ const timedOut = failedPythonResult(pending.input.cellId, `Python kernel timed out after ${timeoutMs}ms`);
237
+ if (pending.hostAbort) {
238
+ void this.#stopHostEntry(pending, timedOut);
239
+ return;
240
+ }
222
241
  if (this.#transport) void this.#beginRetirement(this.#transport).catch(() => undefined);
223
- this.#settleRun(
224
- pending,
225
- failedPythonResult(pending.input.cellId, `Python kernel timed out after ${timeoutMs}ms`),
226
- );
242
+ this.#settleRun(pending, timedOut);
243
+ }
244
+
245
+ /**
246
+ * Aborts a running host entry and settles it only once its executor has stopped (or after the same bound the
247
+ * interpreter gets before escalation), so the next queue entry never overlaps the aborted host work. An
248
+ * executor that finished successfully despite the abort reports that outcome: its work did commit.
249
+ */
250
+ async #stopHostEntry(pending: PendingRun, stopped: ResultMessage): Promise<void> {
251
+ pending.hostAbort?.abort();
252
+ const done = pending.hostDone;
253
+ let bound: ReturnType<typeof setTimeout> | undefined;
254
+ const outcome =
255
+ done === undefined
256
+ ? undefined
257
+ : await Promise.race([
258
+ done,
259
+ new Promise<undefined>((resolve) => {
260
+ bound = setTimeout(() => resolve(undefined), interruptEscalationMs);
261
+ }),
262
+ ]);
263
+ if (bound !== undefined) clearTimeout(bound);
264
+ if (outcome?.ok === true) {
265
+ pending.interruptReason = undefined;
266
+ this.#settleRun(pending, outcome);
267
+ return;
268
+ }
269
+ this.#settleRun(pending, stopped);
227
270
  }
228
271
 
229
272
  async #ensureStarted(): Promise<void> {
@@ -354,6 +397,7 @@ export class PythonKernel {
354
397
  }
355
398
 
356
399
  #removePending(pending: PendingRun): void {
400
+ pending.hostAbort?.abort();
357
401
  if (pending.timeoutTimer) clearTimeout(pending.timeoutTimer);
358
402
  if (pending.escalationTimer) clearTimeout(pending.escalationTimer);
359
403
  pending.timeoutTimer = null;
@@ -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
@@ -48,6 +49,11 @@ EMIT_LOCK = Lock()
48
49
  RESERVED_AGENT_TOOL = "__agent__"
49
50
  RESERVED_OUTPUT_TOOL = "__output__"
50
51
  RESERVED_SCHEMA_TOOL = "__schema__"
52
+ RESERVED_WAIT_TOOL = "__wait__"
53
+ RESERVED_HANDLE_STATUS_TOOL = "__handle_status__"
54
+ RESERVED_HANDLE_OUTPUT_TOOL = "__handle_output__"
55
+ RESERVED_HANDLE_SEND_TOOL = "__handle_send__"
56
+ RESERVED_HANDLE_CANCEL_TOOL = "__handle_cancel__"
51
57
  TIMEOUT_PAUSE_OP = "timeout-pause"
52
58
  TIMEOUT_RESUME_OP = "timeout-resume"
53
59
 
@@ -354,7 +360,15 @@ def write(path: str | Path, content: str) -> Path:
354
360
  return target
355
361
 
356
362
 
357
- def bridge_post(path: str, payload: dict[str, Any]) -> Any:
363
+ # The bridge is a loopback call: urllib's default opener would route it through a configured proxy
364
+ # (the environment everywhere, the registry on Windows), which a 127.0.0.1 request must never use.
365
+ _BRIDGE_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
366
+ # Ordinary bridge calls keep this socket timeout; only `__wait__` passes its own bound (see `_bridge_wait_post`).
367
+ _BRIDGE_SOCKET_TIMEOUT_SECONDS = 60
368
+ _WAIT_SOCKET_GRACE_SECONDS = 30
369
+
370
+
371
+ def bridge_post(path: str, payload: dict[str, Any], *, socket_timeout: float | None = _BRIDGE_SOCKET_TIMEOUT_SECONDS) -> Any:
358
372
  port = CONNECTION.get("port")
359
373
  token = CONNECTION.get("token")
360
374
  if not isinstance(port, int) or not isinstance(token, str):
@@ -369,7 +383,7 @@ def bridge_post(path: str, payload: dict[str, Any]) -> Any:
369
383
  emit_status(TIMEOUT_PAUSE_OP, force=True)
370
384
  try:
371
385
  try:
372
- with urllib.request.urlopen(request, timeout=60) as response:
386
+ with _BRIDGE_OPENER.open(request, timeout=socket_timeout) as response:
373
387
  response_data = response.read()
374
388
  except urllib.error.HTTPError as exc:
375
389
  response_data = exc.read()
@@ -495,6 +509,8 @@ def completion(
495
509
  if schema is not None:
496
510
  options["schema"] = schema
497
511
  response = bridge_post("/completion", {"prompt": prompt, "opts": options})
512
+ if options.get("handle") is True:
513
+ return handle(response)
498
514
  if not isinstance(response, dict):
499
515
  return response
500
516
  if "value" in response:
@@ -611,6 +627,129 @@ def agent(
611
627
  return node
612
628
 
613
629
 
630
+ _HANDLE_KINDS = ("agent", "completion", "workpool")
631
+ _WAIT_MODES = ("all", "any", "settled")
632
+ _HANDLE_USAGE = (
633
+ "handle() expects an agent(..., handle=True) record, a workpool, a completion handle, "
634
+ "or a saved {kind, id, run_epoch} reference"
635
+ )
636
+
637
+
638
+ def _handle_ref(value: Any) -> dict[str, Any]:
639
+ if isinstance(value, HandleView):
640
+ return dict(value.ref)
641
+ if isinstance(value, Workpool):
642
+ return {"kind": "workpool", "id": value.pool_id, "run_epoch": 0}
643
+ if not isinstance(value, dict):
644
+ raise PreludeTypeError(_HANDLE_USAGE)
645
+ if isinstance(value.get("pool_id"), str):
646
+ return {"kind": "workpool", "id": value["pool_id"], "run_epoch": 0}
647
+ kind = value.get("kind")
648
+ if kind not in _HANDLE_KINDS:
649
+ handle_uri = value.get("handle")
650
+ scheme = handle_uri.split("://", 1)[0] if isinstance(handle_uri, str) else None
651
+ kind = scheme if scheme in _HANDLE_KINDS else None
652
+ identity = value.get("id", value.get("task_id"))
653
+ if kind is None or not isinstance(identity, str) or not identity:
654
+ raise PreludeTypeError(_HANDLE_USAGE)
655
+ run_epoch = value.get("run_epoch", 0 if kind == "workpool" else None)
656
+ if isinstance(run_epoch, bool) or not isinstance(run_epoch, int) or run_epoch < 0:
657
+ raise PreludeTypeError(f"{_HANDLE_USAGE}; run_epoch must be a non-negative integer")
658
+ return {"kind": kind, "id": identity, "run_epoch": run_epoch}
659
+
660
+
661
+ def _handle_call(tool_name: str, args: dict[str, Any]) -> Any:
662
+ return bridge_post("/call", {"callId": f"py-{uuid.uuid4()}", "toolName": tool_name, "args": args})
663
+
664
+
665
+ def _bridge_wait_post(args: dict[str, Any], timeout: float | None) -> Any:
666
+ # A long-lived request: no 60 s socket cap. An explicit timeout bounds it (plus grace for the host's
667
+ # reply); without one only the cell's own end does: SIGINT from the host (cancel, the cell's hard limit,
668
+ # which the eval timeout can raise) interrupts the blocking read, which closes the socket and so the
669
+ # host-side subscription.
670
+ socket_timeout = None if timeout is None else float(timeout) + _WAIT_SOCKET_GRACE_SECONDS
671
+ return bridge_post(
672
+ "/call",
673
+ {"callId": f"py-{uuid.uuid4()}", "toolName": RESERVED_WAIT_TOOL, "args": args},
674
+ socket_timeout=socket_timeout,
675
+ )
676
+
677
+
678
+ def wait(handles: Any, *, timeout: float | None = None, mode: str = "all") -> Any:
679
+ """Block until the handles settle; never cancels work. See tool_schema("eval:wait")."""
680
+ if handles is None:
681
+ items: list[Any] = []
682
+ elif isinstance(handles, (list, tuple)):
683
+ items = list(handles)
684
+ else:
685
+ items = [handles]
686
+ if timeout is not None and (
687
+ isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not math.isfinite(timeout) or timeout < 0
688
+ ):
689
+ raise PreludeValueError("wait() timeout must be a finite number of seconds >= 0")
690
+ if mode not in _WAIT_MODES:
691
+ raise PreludeValueError("wait() mode must be 'all', 'any' or 'settled'")
692
+ args: dict[str, Any] = {"refs": [_handle_ref(item) for item in items], "mode": mode}
693
+ if timeout is not None:
694
+ args["timeout"] = timeout
695
+ return _bridge_wait_post(args, timeout)
696
+
697
+
698
+ class HandleControl:
699
+ """Epoch-fenced control over one handle; every call goes through the host capability."""
700
+
701
+ __slots__ = ("_ref",)
702
+
703
+ def __init__(self, ref: dict[str, Any]) -> None:
704
+ self._ref = dict(ref)
705
+
706
+ def __repr__(self) -> str:
707
+ return f"<handle.control {self._ref['kind']}://{self._ref['id']}@{self._ref['run_epoch']}>"
708
+
709
+ def status(self) -> Any:
710
+ return _handle_call(RESERVED_HANDLE_STATUS_TOOL, {"ref": self._ref})
711
+
712
+ def output(self, format: str = "raw", offset: int | None = None, limit: int | None = None) -> Any:
713
+ args: dict[str, Any] = {"ref": self._ref, "format": format}
714
+ if offset is not None:
715
+ args["offset"] = offset
716
+ if limit is not None:
717
+ args["limit"] = limit
718
+ return _handle_call(RESERVED_HANDLE_OUTPUT_TOOL, args)
719
+
720
+ def send(self, message: str) -> Any:
721
+ return _handle_call(RESERVED_HANDLE_SEND_TOOL, {"ref": self._ref, "message": str(message)})
722
+
723
+ def cancel(self) -> Any:
724
+ return _handle_call(RESERVED_HANDLE_CANCEL_TOOL, {"ref": self._ref})
725
+
726
+ def wait(self, timeout: float | None = None) -> Any:
727
+ return wait([self._ref], timeout=timeout, mode="all")[0]
728
+
729
+
730
+ class HandleView(dict):
731
+ """The legacy record's fields as a dict, plus `control` and `ref` as attributes (never keys)."""
732
+
733
+ __slots__ = ("control", "ref")
734
+
735
+ def __init__(self, fields: dict[str, Any], ref: dict[str, Any]) -> None:
736
+ super().__init__(fields)
737
+ self.ref = dict(ref)
738
+ self.control = HandleControl(ref)
739
+
740
+
741
+ def handle(value: Any) -> HandleView:
742
+ """Rich view of an agent record, workpool, completion handle or saved reference; see tool_schema("eval:helpers")."""
743
+ ref = _handle_ref(value)
744
+ fields: dict[str, Any] = {}
745
+ if isinstance(value, dict):
746
+ fields.update({key: item for key, item in value.items() if not callable(item)})
747
+ fields.setdefault("id", ref["id"])
748
+ fields.setdefault("run_epoch", ref["run_epoch"])
749
+ fields.setdefault("handle", f"{ref['kind']}://{ref['id']}")
750
+ return HandleView(fields, ref)
751
+
752
+
614
753
  def _pool_map(items: Iterable[Any], function: Callable[[Any], Any]) -> list[Any]:
615
754
  values = list(items)
616
755
  if not values:
@@ -992,6 +1131,8 @@ USER_NS.update(
992
1131
  "workpool": workpool,
993
1132
  "output": output,
994
1133
  "tool_schema": tool_schema,
1134
+ "wait": wait,
1135
+ "handle": handle,
995
1136
  "__senpi_magic": _magic,
996
1137
  "__senpi_magic_cell": _magic_cell,
997
1138
  "__senpi_shell": _shell,
@@ -1400,7 +1541,7 @@ def elapsed(start: float) -> int:
1400
1541
  return max(0, int((time.monotonic() - start) * 1000))
1401
1542
 
1402
1543
 
1403
- def handle(message: dict[str, Any]) -> bool:
1544
+ def _handle_message(message: dict[str, Any]) -> bool:
1404
1545
  global CONNECTION
1405
1546
  message_type = message.get("type")
1406
1547
  if message_type == "init":
@@ -1478,7 +1619,7 @@ def main() -> None:
1478
1619
  host_closed = False
1479
1620
  for raw in sys.stdin:
1480
1621
  try:
1481
- if not handle(json.loads(raw)):
1622
+ if not _handle_message(json.loads(raw)):
1482
1623
  host_closed = True
1483
1624
  break
1484
1625
  except BaseException as exc: # noqa: BROAD_EXCEPT_OK — process boundary serializes malformed input and interrupts.
@@ -0,0 +1,97 @@
1
+ # In-cell wait()/handle() over the host's handle capability. Mirrors src/bridge/reserved.ts.
2
+ SENPI_RESERVED_WAIT_TOOL = "__wait__"
3
+ SENPI_RESERVED_HANDLE_STATUS_TOOL = "__handle_status__"
4
+ SENPI_RESERVED_HANDLE_OUTPUT_TOOL = "__handle_output__"
5
+ SENPI_RESERVED_HANDLE_SEND_TOOL = "__handle_send__"
6
+ SENPI_RESERVED_HANDLE_CANCEL_TOOL = "__handle_cancel__"
7
+ SENPI_HANDLE_KINDS = ["agent", "completion", "workpool"].freeze
8
+ SENPI_WAIT_MODES = ["all", "any", "settled"].freeze
9
+ SENPI_WAIT_SOCKET_GRACE_SECONDS = 30
10
+ SENPI_HANDLE_USAGE = "handle() expects an agent(..., handle: true) record, a workpool, a completion handle, or a saved {kind, id, run_epoch} reference"
11
+
12
+ def __senpi_handle_ref(value)
13
+ return value.ref.dup if value.respond_to?(:ref) && value.respond_to?(:control)
14
+ return { "kind" => "workpool", "id" => value.pool_id, "run_epoch" => 0 } if value.is_a?(SenpiWorkpool)
15
+ raise TypeError, SENPI_HANDLE_USAGE unless value.is_a?(Hash)
16
+ record = value.transform_keys(&:to_s)
17
+ return { "kind" => "workpool", "id" => record["pool_id"], "run_epoch" => 0 } if record["pool_id"].is_a?(String)
18
+ kind = record["kind"]
19
+ unless SENPI_HANDLE_KINDS.include?(kind)
20
+ scheme = record["handle"].is_a?(String) ? record["handle"].split("://", 2).first : nil
21
+ kind = SENPI_HANDLE_KINDS.include?(scheme) ? scheme : nil
22
+ end
23
+ identity = record.fetch("id", record["task_id"])
24
+ raise TypeError, SENPI_HANDLE_USAGE if kind.nil? || !identity.is_a?(String) || identity.empty?
25
+ run_epoch = record.fetch("run_epoch", kind == "workpool" ? 0 : nil)
26
+ raise TypeError, "#{SENPI_HANDLE_USAGE}; run_epoch must be a non-negative integer" unless run_epoch.is_a?(Integer) && run_epoch >= 0
27
+ { "kind" => kind, "id" => identity, "run_epoch" => run_epoch }
28
+ end
29
+
30
+ # A long-lived request: ordinary calls keep their 60 s read timeout; this one is bounded by the explicit
31
+ # timeout (plus grace for the host's reply) or else only by the cell's own end: SIGINT from the host (cancel,
32
+ # the cell's hard limit) interrupts the read, which closes the socket and the host-side subscription.
33
+ def __senpi_wait_post(args, timeout)
34
+ read_timeout = timeout.nil? ? nil : timeout.to_f + SENPI_WAIT_SOCKET_GRACE_SECONDS
35
+ payload = { "callId" => "rb-#{Process.pid}-#{rand(1_000_000)}", "toolName" => SENPI_RESERVED_WAIT_TOOL, "args" => args }
36
+ __senpi_bridge_request("/call", payload, read_timeout: read_timeout)
37
+ end
38
+
39
+ def __senpi_wait(handles, timeout: nil, mode: "all")
40
+ items = handles.nil? ? [] : handles.is_a?(Array) ? handles : [handles]
41
+ unless timeout.nil? || (timeout.is_a?(Numeric) && timeout.to_f.finite? && timeout >= 0)
42
+ raise ArgumentError, "wait() timeout must be a finite number of seconds >= 0"
43
+ end
44
+ raise ArgumentError, "wait() mode must be 'all', 'any' or 'settled'" unless SENPI_WAIT_MODES.include?(mode.to_s)
45
+ args = { "refs" => items.map { |item| __senpi_handle_ref(item) }, "mode" => mode.to_s }
46
+ args["timeout"] = timeout unless timeout.nil?
47
+ __senpi_wait_post(args, timeout)
48
+ end
49
+
50
+ class SenpiHandleControl
51
+ def initialize(ref)
52
+ @ref = ref.dup.freeze
53
+ freeze
54
+ end
55
+
56
+ def status
57
+ __senpi_call_tool(SENPI_RESERVED_HANDLE_STATUS_TOOL, { "ref" => @ref })
58
+ end
59
+
60
+ def output(format: "raw", offset: nil, limit: nil)
61
+ args = { "ref" => @ref, "format" => format }
62
+ args["offset"] = offset unless offset.nil?
63
+ args["limit"] = limit unless limit.nil?
64
+ __senpi_call_tool(SENPI_RESERVED_HANDLE_OUTPUT_TOOL, args)
65
+ end
66
+
67
+ def send_message(message)
68
+ __senpi_call_tool(SENPI_RESERVED_HANDLE_SEND_TOOL, { "ref" => @ref, "message" => message.to_s })
69
+ end
70
+ # Deliberately shadows Object#send so control.send(msg) matches the other languages; use __send__ for reflection.
71
+ alias send send_message
72
+
73
+ def cancel
74
+ __senpi_call_tool(SENPI_RESERVED_HANDLE_CANCEL_TOOL, { "ref" => @ref })
75
+ end
76
+
77
+ def wait(timeout: nil)
78
+ __senpi_wait([@ref], timeout: timeout, mode: "all")[0]
79
+ end
80
+
81
+ def inspect
82
+ "#<handle.control #{@ref["kind"]}://#{@ref["id"]}@#{@ref["run_epoch"]}>"
83
+ end
84
+ end
85
+
86
+ # The legacy record's fields as a Hash copy; `control` and `ref` are singleton methods, never keys.
87
+ def __senpi_handle_view(value)
88
+ ref = __senpi_handle_ref(value)
89
+ view = value.is_a?(Hash) ? value.transform_keys(&:to_s).reject { |_key, item| item.respond_to?(:call) } : {}
90
+ view["id"] ||= ref["id"]
91
+ view["run_epoch"] ||= ref["run_epoch"]
92
+ view["handle"] ||= "#{ref["kind"]}://#{ref["id"]}"
93
+ control = SenpiHandleControl.new(ref)
94
+ view.define_singleton_method(:control) { control }
95
+ view.define_singleton_method(:ref) { ref.dup }
96
+ view
97
+ end
@@ -2,6 +2,7 @@ import { join } from "node:path";
2
2
  import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
3
3
  import type { SessionEnvironment } from "../session-env.ts";
4
4
  import type { KernelLifecycle } from "../shared/kernel-death.ts";
5
+ import { readProcessGroupCpuTime } from "../shared/process-group-cpu.ts";
5
6
  import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
6
7
  import { SubprocessKernel, type SubprocessKernelMemory, type SubprocessSpawn } from "../shared/subprocess-kernel.ts";
7
8
 
@@ -14,7 +15,7 @@ export interface RubyKernelStartOptions extends KernelLifecycle {
14
15
  readonly command?: string;
15
16
  readonly spawn?: SubprocessSpawn;
16
17
  readonly onMessage?: (message: KernelToHostMessage) => void;
17
- /** Ceiling-only memory management (no notice or globals list: the runner reports no memory). */
18
+ /** Memory management: the runner reports its largest globals and the host reads the interpreter footprint for the ceiling. */
18
19
  readonly memory?: SubprocessKernelMemory;
19
20
  }
20
21
 
@@ -43,6 +44,7 @@ export class RubyKernel extends SubprocessKernel {
43
44
  onMessage: options.onMessage,
44
45
  memory: options.memory && { language: "rb", ...options.memory },
45
46
  onDeath: options.onDeath,
47
+ startup: { label: "Ruby", readGroupCpuTime: readProcessGroupCpuTime },
46
48
  });
47
49
  }
48
50
  }
@@ -0,0 +1,37 @@
1
+ # Path resolution (local:// roots) and display payload shaping for the Ruby prelude helpers.
2
+ def __senpi_resolve_path(value)
3
+ raw = value.to_s
4
+ match = SENPI_INTERNAL_URL.match(raw)
5
+ return File.expand_path(raw) unless match
6
+
7
+ scheme = match[1].downcase
8
+ roots = $__senpi_connection.is_a?(Hash) ? $__senpi_connection["localRoots"] : nil
9
+ root = roots[scheme] if roots.is_a?(Hash)
10
+ raise "Protocol paths are not supported by this helper: #{raw}" unless root.is_a?(String) && !root.empty?
11
+
12
+ relative = URI::DEFAULT_PARSER.unescape(match[2].tr("\\", "/"))
13
+ root_path = File.expand_path(root)
14
+ return root_path if relative.empty?
15
+ if relative.start_with?("/") || relative.split("/").include?("..")
16
+ raise "Unsafe #{scheme}:// path (absolute or traversal): #{raw}"
17
+ end
18
+
19
+ resolved = File.expand_path(relative, root_path)
20
+ unless resolved == root_path || resolved.start_with?(root_path + File::SEPARATOR)
21
+ raise "#{scheme}:// path escapes its root: #{raw}"
22
+ end
23
+ resolved
24
+ end
25
+
26
+ def __senpi_display_payload(value)
27
+ if value.is_a?(Hash)
28
+ kind = value["type"] || value[:type]
29
+ text_value = value["text"] || value[:text]
30
+ return ["text/markdown", text_value.to_s] if kind == "markdown" && !text_value.nil?
31
+ return ["image/png", value["data"].to_s] if kind == "image" && value["mimeType"] == "image/png"
32
+ return ["image/jpeg", value["data"].to_s] if kind == "image" && value["mimeType"] == "image/jpeg"
33
+ return ["application/json", JSON.generate(value)]
34
+ end
35
+ return ["application/json", JSON.generate(value)] if value.is_a?(Array)
36
+ ["text/plain", value.to_s]
37
+ end
@@ -19,42 +19,7 @@ def __senpi_emit_status(op, fields = {}, force: false)
19
19
  __senpi_emit({ "type" => "status", "event" => { "op" => op }.merge(fields.transform_keys(&:to_s)) })
20
20
  end
21
21
 
22
- def __senpi_resolve_path(value)
23
- raw = value.to_s
24
- match = SENPI_INTERNAL_URL.match(raw)
25
- return File.expand_path(raw) unless match
26
-
27
- scheme = match[1].downcase
28
- roots = $__senpi_connection.is_a?(Hash) ? $__senpi_connection["localRoots"] : nil
29
- root = roots[scheme] if roots.is_a?(Hash)
30
- raise "Protocol paths are not supported by this helper: #{raw}" unless root.is_a?(String) && !root.empty?
31
-
32
- relative = URI::DEFAULT_PARSER.unescape(match[2].tr("\\", "/"))
33
- root_path = File.expand_path(root)
34
- return root_path if relative.empty?
35
- if relative.start_with?("/") || relative.split("/").include?("..")
36
- raise "Unsafe #{scheme}:// path (absolute or traversal): #{raw}"
37
- end
38
-
39
- resolved = File.expand_path(relative, root_path)
40
- unless resolved == root_path || resolved.start_with?(root_path + File::SEPARATOR)
41
- raise "#{scheme}:// path escapes its root: #{raw}"
42
- end
43
- resolved
44
- end
45
-
46
- def __senpi_display_payload(value)
47
- if value.is_a?(Hash)
48
- kind = value["type"] || value[:type]
49
- text_value = value["text"] || value[:text]
50
- return ["text/markdown", text_value.to_s] if kind == "markdown" && !text_value.nil?
51
- return ["image/png", value["data"].to_s] if kind == "image" && value["mimeType"] == "image/png"
52
- return ["image/jpeg", value["data"].to_s] if kind == "image" && value["mimeType"] == "image/jpeg"
53
- return ["application/json", JSON.generate(value)]
54
- end
55
- return ["application/json", JSON.generate(value)] if value.is_a?(Array)
56
- ["text/plain", value.to_s]
57
- end
22
+ require_relative "paths"
58
23
 
59
24
  def display(value)
60
25
  mime_type, payload = __senpi_display_payload(value)
@@ -118,7 +83,7 @@ def env(key = nil, value = nil)
118
83
  resolved
119
84
  end
120
85
 
121
- def __senpi_bridge_request(path, payload)
86
+ def __senpi_bridge_request(path, payload, read_timeout: 60)
122
87
  connection = $__senpi_connection
123
88
  raise "Ruby tool bridge is not initialized" unless connection.is_a?(Hash)
124
89
  port = connection["port"]
@@ -132,7 +97,7 @@ def __senpi_bridge_request(path, payload)
132
97
  request.body = JSON.generate(payload)
133
98
  __senpi_emit_status("timeout-pause", force: true)
134
99
  begin
135
- response = Net::HTTP.start(uri.hostname, uri.port, open_timeout: 10, read_timeout: 60) { |http| http.request(request) }
100
+ response = Net::HTTP.start(uri.hostname, uri.port, open_timeout: 10, read_timeout: read_timeout) { |http| http.request(request) }
136
101
  ensure
137
102
  __senpi_emit_status("timeout-resume", force: true)
138
103
  end
@@ -187,12 +152,24 @@ def tool
187
152
  end
188
153
 
189
154
  require_relative "workpool"
155
+ require_relative "handles"
156
+
157
+ # Block until the handles settle; never cancels work. See tool_schema("eval:wait").
158
+ def wait(handles, timeout: nil, mode: "all")
159
+ __senpi_wait(handles, timeout: timeout, mode: mode)
160
+ end
161
+
162
+ # Rich view of an agent record, workpool, completion handle or saved reference; see tool_schema("eval:helpers").
163
+ def handle(value)
164
+ __senpi_handle_view(value)
165
+ end
190
166
 
191
167
  def completion(prompt, model: "default", system: nil, schema: nil, **kwargs)
192
168
  options = { "model" => model }.merge(kwargs.transform_keys(&:to_s))
193
169
  options["system"] = system unless system.nil?
194
170
  options["schema"] = schema unless schema.nil?
195
171
  result = __senpi_bridge_request("/completion", { "prompt" => prompt.to_s, "opts" => options })
172
+ return handle(result) if options["handle"] == true
196
173
  return result unless result.is_a?(Hash)
197
174
  return result["value"] if result.key?("value")
198
175