@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
@@ -1,4 +1,6 @@
1
1
  # allow: SIZE_OK — parser, stream capture, bridge calls, and the persistent execution loop share Main globals.
2
+ write(stdout, "{\"type\":\"status\",\"event\":{\"op\":\"kernel-startup\",\"stage\":\"stdlib-imports\"}}\n")
3
+ flush(stdout)
2
4
  using Sockets
3
5
 
4
6
  const SENPI_ORIGINAL_STDOUT = stdout
@@ -7,6 +9,8 @@ out_read, out_write = redirect_stdout()
7
9
  err_read, err_write = redirect_stderr()
8
10
  redirect_stdin(devnull)
9
11
 
12
+ write(SENPI_ORIGINAL_STDOUT, "{\"type\":\"status\",\"event\":{\"op\":\"kernel-startup\",\"stage\":\"runtime-init\"}}\n")
13
+ flush(SENPI_ORIGINAL_STDOUT)
10
14
  include("prelude.jl")
11
15
 
12
16
  const senpi_connection = Dict{String, Any}()
@@ -220,12 +224,15 @@ function senpi_http_body(response::AbstractString)
220
224
  String(output)
221
225
  end
222
226
 
223
- function senpi_bridge_request(path::String, payload)
227
+ # `read_timeout` (seconds) bounds only the long-lived `wait()` request; ordinary calls read until the host closes.
228
+ function senpi_bridge_request(path::String, payload; read_timeout=nothing)
224
229
  port = get(senpi_connection, "port", nothing)
225
230
  token = get(senpi_connection, "token", nothing)
226
231
  port isa Integer && token isa AbstractString || error("Julia tool bridge is not initialized")
227
232
  body = senpi_json(payload)
228
233
  socket = connect(ip"127.0.0.1", port)
234
+ timed_out = Ref(false)
235
+ timer = read_timeout === nothing ? nothing : Timer(_ -> (timed_out[] = true; close(socket)), Float64(read_timeout))
229
236
  try
230
237
  request = join([
231
238
  "POST " * path * " HTTP/1.1",
@@ -240,12 +247,14 @@ function senpi_bridge_request(path::String, payload)
240
247
  Base.write(socket, request)
241
248
  flush(socket)
242
249
  response = Base.read(socket, String)
250
+ timed_out[] && throw(SenpiBridgeError("bridge request timed out after $(read_timeout)s", "bridge_timeout"))
243
251
  parsed = senpi_json_parse(senpi_http_body(response))
244
252
  parsed isa AbstractDict || error("Bridge returned invalid JSON")
245
253
  get(parsed, "ok", false) === true && return get(parsed, "value", nothing)
246
254
  failure = get(parsed, "error", parsed)
247
255
  throw(SenpiBridgeError(failure isa AbstractDict ? string(get(failure, "message", failure)) : string(failure), failure isa AbstractDict ? get(failure, "code", nothing) : nothing))
248
256
  finally
257
+ timer === nothing || close(timer)
249
258
  close(socket)
250
259
  end
251
260
  end
@@ -281,6 +290,152 @@ function senpi_set_connection(value)
281
290
  end
282
291
  end
283
292
 
293
+ const senpi_memory = Ref{Any}(nothing)
294
+
295
+ const SENPI_MEMORY_INTERNALS = Set([:senpi_current_cell, :senpi_memory, :senpi_connection, :senpi_frame_io, :senpi_stdout_capture, :senpi_stderr_capture, :senpi_protocol_stdin])
296
+
297
+ const SENPI_SIZER_SAMPLE = 1_000
298
+ const SENPI_SIZER_NODE_BUDGET = 5_000
299
+ const SENPI_SIZER_MAX_DEPTH = 64
300
+ const SENPI_SIZER_POINTER = 8
301
+ const SENPI_SIZER_OBJECT = 16
302
+ const SENPI_SIZER_MIN_REPORTED = 1024 * 1024
303
+
304
+ # Sizes a global with sampling and a node budget per global (leaves count too), so one deep global
305
+ # never hides the ones measured after it and a huge container costs a bounded walk. Only concrete Base
306
+ # containers are iterated; any other AbstractDict or AbstractSet is sized as an opaque struct, so no user
307
+ # length or iterate method runs during a memory report.
308
+ mutable struct SenpiSizer
309
+ seen::IdDict{Any, Nothing}
310
+ nodes::Int
311
+ approximate::Bool
312
+ end
313
+ SenpiSizer() = SenpiSizer(IdDict{Any, Nothing}(), 0, false)
314
+
315
+ senpi_over_budget(sizer::SenpiSizer) = sizer.nodes >= SENPI_SIZER_NODE_BUDGET
316
+
317
+ function senpi_size(sizer::SenpiSizer, value, depth::Int)::Int
318
+ sizer.nodes += 1
319
+ value isa Type && return 0
320
+ value isa Union{Number, Char, Bool, Symbol, Nothing} && return isbits(value) ? sizeof(value) : 0
321
+ value isa String && return SENPI_SIZER_OBJECT + sizeof(value)
322
+ if ismutable(value)
323
+ haskey(sizer.seen, value) && return 0
324
+ sizer.seen[value] = nothing
325
+ end
326
+ if depth >= SENPI_SIZER_MAX_DEPTH || senpi_over_budget(sizer)
327
+ sizer.approximate = true
328
+ return SENPI_SIZER_OBJECT
329
+ end
330
+ if value isa Array
331
+ isbitstype(eltype(value)) && return SENPI_SIZER_OBJECT + sizeof(value)
332
+ return SENPI_SIZER_OBJECT + length(value) * SENPI_SIZER_POINTER +
333
+ senpi_sampled(sizer, length(value), depth) do index
334
+ isassigned(value, index) ? value[index] : nothing
335
+ end
336
+ elseif value isa Union{Dict, IdDict}
337
+ count = length(value)
338
+ total = 0
339
+ taken = 0
340
+ for (key, item) in value
341
+ senpi_over_budget(sizer) && break
342
+ total += senpi_size(sizer, key, depth + 1) + senpi_size(sizer, item, depth + 1)
343
+ taken += 1
344
+ taken >= SENPI_SIZER_SAMPLE && break
345
+ end
346
+ taken < count && (sizer.approximate = true)
347
+ return SENPI_SIZER_OBJECT + count * 2 * SENPI_SIZER_POINTER + (taken == 0 ? 0 : div(total * count, taken))
348
+ elseif value isa Union{Set, Tuple}
349
+ count = length(value)
350
+ total = 0
351
+ taken = 0
352
+ for item in value
353
+ senpi_over_budget(sizer) && break
354
+ total += senpi_size(sizer, item, depth + 1)
355
+ taken += 1
356
+ taken >= SENPI_SIZER_SAMPLE && break
357
+ end
358
+ taken < count && (sizer.approximate = true)
359
+ return SENPI_SIZER_OBJECT + count * SENPI_SIZER_POINTER + (taken == 0 ? 0 : div(total * count, taken))
360
+ end
361
+ isbits(value) && return sizeof(value)
362
+ fields = fieldcount(typeof(value))
363
+ return SENPI_SIZER_OBJECT + fields * SENPI_SIZER_POINTER +
364
+ senpi_sampled(sizer, fields, depth) do index
365
+ isdefined(value, index) ? getfield(value, index) : nothing
366
+ end
367
+ end
368
+
369
+ # Up to SENPI_SIZER_SAMPLE evenly spaced elements; once the budget runs out part-way, the elements measured
370
+ # so far stand in for the rest.
371
+ function senpi_sampled(at, sizer::SenpiSizer, count::Int, depth::Int)::Int
372
+ count == 0 && return 0
373
+ picks = min(count, SENPI_SIZER_SAMPLE)
374
+ picks < count && (sizer.approximate = true)
375
+ step = count / picks
376
+ total = 0
377
+ measured = 0
378
+ for sample in 1:picks
379
+ if senpi_over_budget(sizer)
380
+ sizer.approximate = true
381
+ break
382
+ end
383
+ total += senpi_size(sizer, at(1 + floor(Int, (sample - 1) * step)), depth + 1)
384
+ measured += 1
385
+ end
386
+ return measured == 0 ? 0 : round(Int, total / measured * count)
387
+ end
388
+
389
+ function senpi_largest_globals(limit::Int)
390
+ try
391
+ sizer = SenpiSizer()
392
+ candidates = Tuple{String, Int, Bool}[]
393
+ Base.invokelatest() do
394
+ for name in names(Main, all = true)
395
+ name in SENPI_MEMORY_INTERNALS && continue
396
+ name in (:Main, :Base, :Core, :Ans, :ans) && continue
397
+ lowered = lowercase(string(name))
398
+ startswith(lowered, "senpi_") && continue
399
+ startswith(string(name), "Senpi") && continue
400
+ startswith(string(name), "#") && continue
401
+ isdefined(Main, name) || continue
402
+ value = getfield(Main, name)
403
+ value isa Module && continue
404
+ value isa IO && continue
405
+ value isa Type && continue
406
+ value isa Function && continue
407
+ sizer.approximate = false
408
+ sizer.nodes = 0
409
+ bytes = try
410
+ senpi_size(sizer, value, 0) + SENPI_SIZER_POINTER
411
+ catch
412
+ 0
413
+ end
414
+ bytes >= SENPI_SIZER_MIN_REPORTED && push!(candidates, (string(name), bytes, sizer.approximate))
415
+ end
416
+ end
417
+ sort!(candidates, by = entry -> entry[2], rev = true)
418
+ [approximate ? Dict{String, Any}("name" => name, "bytes" => bytes, "approximate" => true) : Dict{String, Any}("name" => name, "bytes" => bytes) for (name, bytes, approximate) in candidates[1:min(limit, end)]]
419
+ catch
420
+ Dict{String, Any}[]
421
+ end
422
+ end
423
+
424
+ function senpi_memory_report()
425
+ try
426
+ senpi_memory[] === nothing && return nothing
427
+ # The host reads the interpreter's current footprint and decides on the notice; the runner only
428
+ # names its largest globals, with a bounded walk, so a cell never pays for an unbounded scan.
429
+ named = senpi_largest_globals(5)
430
+ # The host replaces liveBytes with the interpreter footprint it reads itself; only globals is used.
431
+ report = Dict{String, Any}("liveBytes" => 0, "measure" => "footprint")
432
+ isempty(named) || (report["globals"] = named)
433
+ report
434
+ catch
435
+ nothing
436
+ end
437
+ end
438
+
284
439
  function senpi_run_cell(message)
285
440
  cell_id = string(get(message, "cellId", ""))
286
441
  code = string(get(message, "code", ""))
@@ -297,6 +452,8 @@ function senpi_run_cell(message)
297
452
  yield()
298
453
  frame = Dict{String, Any}("type" => "result", "cellId" => cell_id, "ok" => true, "durationMs" => round(Int, (time() - started) * 1000))
299
454
  value !== nothing && senpi_should_display_result(parsed) && (frame["valueRepr"] = senpi_json(value))
455
+ memory = senpi_memory_report()
456
+ memory !== nothing && (frame["memory"] = memory)
300
457
  senpi_emit(frame)
301
458
  catch error
302
459
  senpi_emit(Dict("type" => "result", "cellId" => cell_id, "ok" => false, "error" => senpi_error(error), "durationMs" => round(Int, (time() - started) * 1000)))
@@ -313,7 +470,9 @@ while !eof(SENPI_ORIGINAL_STDIN)
313
470
  message isa AbstractDict || error("Bridge frame must be an object")
314
471
  kind = get(message, "type", nothing)
315
472
  if kind == "init"
473
+ senpi_emit(Dict("type" => "status", "event" => Dict("op" => "kernel-startup", "stage" => "host-init")))
316
474
  senpi_set_connection(get(message, "connection", nothing))
475
+ senpi_memory[] = get(message, "memory", nothing)
317
476
  senpi_emit(Dict("type" => "ready"))
318
477
  elseif kind == "run"
319
478
  senpi_run_cell(message)
@@ -1,6 +1,7 @@
1
1
  import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
2
2
  import { CHILD_LIFECYCLE_OP, INTERRUPT_ACK_OP } from "../../bridge/reserved.ts";
3
3
  import type { KernelInterruptHandle } from "../../tool/types.ts";
4
+ import { KernelToolHostPump } from "../shared/kernel-tools-pump.ts";
4
5
  import { ActiveCellControl } from "./active-cell-control.ts";
5
6
  import { DEFAULT_INTERRUPT_BOUNDS, type WorkerRetirement } from "./interrupt-bounds.ts";
6
7
  import {
@@ -14,7 +15,6 @@ import {
14
15
  } from "./kernel-contract.ts";
15
16
  import { type JavaScriptMemoryReading, KernelMemoryBridge } from "./kernel-memory-bridge.ts";
16
17
  import { kernelToolError } from "./kernel-tools-errors.ts";
17
- import { KernelToolHostPump } from "./kernel-tools-host.ts";
18
18
  import type {
19
19
  KernelToolsDescribeResult,
20
20
  KernelToolsInvokeOptions,
@@ -0,0 +1,93 @@
1
+ // In-cell `wait()` / `handle()` over the host's handle capability. Mirrors src/bridge/reserved.ts; this
2
+ // worker asset cannot import TypeScript. Every call rides the ordinary bridge-call path (`callTool`), so
3
+ // the run budget pauses while the cell is parked and the hard limit still applies.
4
+ const RESERVED_WAIT_TOOL = "__wait__";
5
+ const RESERVED_HANDLE_STATUS_TOOL = "__handle_status__";
6
+ const RESERVED_HANDLE_OUTPUT_TOOL = "__handle_output__";
7
+ const RESERVED_HANDLE_SEND_TOOL = "__handle_send__";
8
+ const RESERVED_HANDLE_CANCEL_TOOL = "__handle_cancel__";
9
+ const KINDS = new Set(["agent", "completion", "workpool"]);
10
+ const WAIT_MODES = new Set(["all", "any", "settled"]);
11
+ const USAGE = "handle() expects an agent(..., {handle: true}) record, a workpool, a completion handle, or a saved {kind, id, run_epoch} reference";
12
+
13
+ export function createHandleHelpers(callTool) {
14
+ const wait = async (handles, options) => {
15
+ const list = Array.isArray(handles) ? handles : handles === undefined || handles === null ? [] : [handles];
16
+ const refs = list.map(toHandleRef);
17
+ const { timeout, mode } = waitOptions(options);
18
+ return await callTool(RESERVED_WAIT_TOOL, {
19
+ refs,
20
+ ...(timeout === undefined ? {} : { timeout }),
21
+ ...(mode === undefined ? {} : { mode }),
22
+ });
23
+ };
24
+ const handle = value => {
25
+ const ref = toHandleRef(value);
26
+ const view = Object.create(Object.prototype);
27
+ if (isPlainObject(value)) {
28
+ for (const [key, field] of Object.entries(value)) {
29
+ if (typeof field !== "function") view[key] = field;
30
+ }
31
+ }
32
+ if (!Object.hasOwn(view, "id")) view.id = ref.id;
33
+ if (!Object.hasOwn(view, "run_epoch")) view.run_epoch = ref.run_epoch;
34
+ if (!Object.hasOwn(view, "handle")) view.handle = `${ref.kind}://${ref.id}`;
35
+ const control = Object.freeze({
36
+ status: async () => await callTool(RESERVED_HANDLE_STATUS_TOOL, { ref }),
37
+ output: async (opts = {}) => await callTool(RESERVED_HANDLE_OUTPUT_TOOL, { ref, ...outputOptions(opts) }),
38
+ send: async message => await callTool(RESERVED_HANDLE_SEND_TOOL, { ref, message: String(message) }),
39
+ cancel: async () => await callTool(RESERVED_HANDLE_CANCEL_TOOL, { ref }),
40
+ wait: async (opts = {}) => (await wait([ref], { ...waitOptions(opts), mode: "all" }))[0],
41
+ });
42
+ Object.defineProperty(view, "ref", { value: Object.freeze({ ...ref }), enumerable: false });
43
+ Object.defineProperty(view, "control", { value: control, enumerable: false });
44
+ return view;
45
+ };
46
+ return { wait, handle };
47
+ }
48
+
49
+ /** Accepts legacy agent records, handle() views, completion handles, workpools and saved refs. */
50
+ export function toHandleRef(value) {
51
+ if (!isPlainObject(value)) throw new TypeError(USAGE);
52
+ if (isPlainObject(value.ref) && KINDS.has(value.ref.kind)) return toHandleRef(value.ref);
53
+ if (typeof value.pool_id === "string") return { kind: "workpool", id: value.pool_id, run_epoch: 0 };
54
+ const kind = KINDS.has(value.kind) ? value.kind : kindFromHandle(value.handle);
55
+ const id = value.id ?? value.task_id;
56
+ if (kind === undefined || typeof id !== "string" || id.length === 0) throw new TypeError(USAGE);
57
+ const run_epoch = value.run_epoch ?? (kind === "workpool" ? 0 : undefined);
58
+ if (!Number.isInteger(run_epoch) || run_epoch < 0) throw new TypeError(`${USAGE}; run_epoch must be a non-negative integer`);
59
+ return { kind, id, run_epoch };
60
+ }
61
+
62
+ function kindFromHandle(handle) {
63
+ if (typeof handle !== "string") return undefined;
64
+ const scheme = handle.split("://")[0];
65
+ return KINDS.has(scheme) ? scheme : undefined;
66
+ }
67
+
68
+ function waitOptions(options) {
69
+ if (options === undefined || options === null) return {};
70
+ if (!isPlainObject(options)) throw new TypeError("wait() accepts { timeout, mode }");
71
+ for (const key of Object.keys(options)) {
72
+ if (key !== "timeout" && key !== "mode") throw new TypeError("wait() accepts { timeout, mode }");
73
+ }
74
+ const { timeout, mode } = options;
75
+ if (timeout !== undefined && timeout !== null && (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout < 0)) {
76
+ throw new TypeError("wait() timeout must be a finite number of seconds >= 0");
77
+ }
78
+ if (mode !== undefined && mode !== null && !WAIT_MODES.has(mode)) throw new TypeError('wait() mode must be "all", "any" or "settled"');
79
+ return { ...(timeout === undefined || timeout === null ? {} : { timeout }), ...(mode === undefined || mode === null ? {} : { mode }) };
80
+ }
81
+
82
+ function outputOptions(options) {
83
+ if (!isPlainObject(options)) throw new TypeError("control.output() accepts { format, offset, limit }");
84
+ const picked = {};
85
+ for (const key of ["format", "offset", "limit"]) {
86
+ if (options[key] !== undefined && options[key] !== null) picked[key] = options[key];
87
+ }
88
+ return picked;
89
+ }
90
+
91
+ function isPlainObject(value) {
92
+ return value !== null && typeof value === "object" && !Array.isArray(value);
93
+ }
@@ -2,9 +2,15 @@ export type KernelToolFunction = {
2
2
  readonly name: string;
3
3
  };
4
4
 
5
+ export type KernelToolNamespaceRegistry = {
6
+ readonly defined?: () => string[];
7
+ readonly undefine?: (name: unknown) => boolean;
8
+ };
9
+
5
10
  export function createToolNamespace(
6
11
  define: (fn: KernelToolFunction, metadata?: unknown) => unknown,
7
12
  callHost: (name: string, args: unknown) => Promise<unknown>,
13
+ registry?: KernelToolNamespaceRegistry,
8
14
  ): ((fn: KernelToolFunction, metadata?: unknown) => unknown) & {
9
15
  readonly [name: string]: (args?: unknown) => Promise<unknown>;
10
16
  };
@@ -1,4 +1,4 @@
1
- export function createToolNamespace(define, callHost) {
1
+ export function createToolNamespace(define, callHost, registry = {}) {
2
2
  const registrar = function tool(fn, metadata) {
3
3
  return define(fn, metadata);
4
4
  };
@@ -6,6 +6,8 @@ export function createToolNamespace(define, callHost) {
6
6
  get(target, prop) {
7
7
  if (typeof prop !== "string") return undefined;
8
8
  if (prop in Function.prototype || prop === "arguments" || prop === "caller") return target[prop];
9
+ if (prop === "defined" && registry.defined) return () => registry.defined();
10
+ if (prop === "undefine" && registry.undefine) return (name) => registry.undefine(name);
9
11
  return async (args) => await callHost(prop, args ?? {});
10
12
  },
11
13
  });
@@ -22,6 +22,8 @@ export function createKernelToolRegistry(options?: KernelToolRegistryOptions): {
22
22
  },
23
23
  signal?: AbortSignal,
24
24
  ): Promise<unknown>;
25
+ defined(): string[];
26
+ undefine(name: unknown): boolean;
25
27
  bumpGeneration(): number;
26
28
  setCollisionNames(hostToolNames?: readonly string[], foreignLanguageNames?: readonly string[]): void;
27
29
  };
@@ -6,7 +6,8 @@ import { orderedArgs, resolveToolMetadata, validateInvokeArgs } from "./kernel-t
6
6
 
7
7
  export { createToolNamespace };
8
8
 
9
- const DEFAULT_RESERVED = Object.freeze(["__agent__", "__output__", "__schema__"]);
9
+ // "defined" and "undefine" are the tool namespace's own members, so a kernel tool by those names could never be reached.
10
+ const DEFAULT_RESERVED = Object.freeze(["__agent__", "__output__", "__schema__", "defined", "undefine"]);
10
11
 
11
12
  function currentNames(source) {
12
13
  return typeof source === "function" ? source() : (source ?? []);
@@ -47,21 +48,22 @@ export function createKernelToolRegistry(options = {}) {
47
48
  assertJs();
48
49
  const parsed = parseToolFunction(fn);
49
50
  const resolved = resolveToolMetadata(metadata, parsed.params);
50
- if (sanitizeNamePart(parsed.name) !== parsed.name || parsed.name.length > MCP_TOOL_NAME_MAX_LENGTH) {
51
+ const name = resolved.name ?? parsed.name;
52
+ if (sanitizeNamePart(name) !== name || name.length > MCP_TOOL_NAME_MAX_LENGTH) {
51
53
  throw kernelToolError("invalid_tool_definition", "Kernel tool name must match MCP name grammar");
52
54
  }
53
- const normalizedName = parsed.name;
54
- const key = kernelToolKey(parsed.name);
55
- if (reservedKeys.has(key)) throw kernelToolError("reserved_tool_name", `Kernel tool name is reserved: ${parsed.name}`);
55
+ const normalizedName = name;
56
+ const key = kernelToolKey(name);
57
+ if (reservedKeys.has(key)) throw kernelToolError("reserved_tool_name", `Kernel tool name is reserved: ${name}`);
56
58
  if (hasNameKey(hostSource, key) || hasNameKey(foreignSource, key)) {
57
- throw kernelToolError("tool_name_collision", `Kernel tool name collides: ${parsed.name}`);
59
+ throw kernelToolError("tool_name_collision", `Kernel tool name collides: ${name}`);
58
60
  }
59
61
  const existing = entries.get(key);
60
- if (existing && existing.originalName !== parsed.name) {
61
- throw kernelToolError("tool_name_collision", `Kernel tool name collides: ${parsed.name}`);
62
+ if (existing && existing.originalName !== name) {
63
+ throw kernelToolError("tool_name_collision", `Kernel tool name collides: ${name}`);
62
64
  }
63
65
  const entry = {
64
- originalName: parsed.name,
66
+ originalName: name,
65
67
  normalizedName,
66
68
  fn,
67
69
  params: parsed.params,
@@ -114,6 +116,13 @@ export function createKernelToolRegistry(options = {}) {
114
116
  throw kernelToolError("kernel_tool_failed", error instanceof Error ? error.message : String(error));
115
117
  }
116
118
  },
119
+ defined() {
120
+ return [...entries.values()].map((entry) => entry.normalizedName).sort();
121
+ },
122
+ undefine(name) {
123
+ if (typeof name !== "string") return false;
124
+ return entries.delete(kernelToolKey(name));
125
+ },
117
126
  bumpGeneration() {
118
127
  generation += 1;
119
128
  entries.clear();
@@ -18,7 +18,11 @@ export function resolveToolMetadata(metadata, params) {
18
18
  if (typeof description !== "string") {
19
19
  throw kernelToolError("invalid_tool_definition", "tool() description must be a string");
20
20
  }
21
- if (metadata.schema === undefined) return { description, input_schema: defaultInputSchema(params) };
21
+ if (metadata.name !== undefined && typeof metadata.name !== "string") {
22
+ throw kernelToolError("invalid_tool_definition", "tool() name must be a string");
23
+ }
24
+ const named = metadata.name === undefined ? {} : { name: metadata.name };
25
+ if (metadata.schema === undefined) return { ...named, description, input_schema: defaultInputSchema(params) };
22
26
  const schema = metadata.schema;
23
27
  if (!isPlainJsonObject(schema) || schema.type !== "object" || !isPlainJsonObject(schema.properties)) {
24
28
  throw kernelToolError("invalid_tool_definition", "tool() metadata schema must be a JSON object schema");
@@ -27,7 +31,7 @@ export function resolveToolMetadata(metadata, params) {
27
31
  if (propertyKeys.length !== params.length || params.some((name) => !Object.hasOwn(schema.properties, name))) {
28
32
  throw kernelToolError("invalid_tool_definition", "tool() metadata schema properties must match parameters");
29
33
  }
30
- return { description, input_schema: schema };
34
+ return { ...named, description, input_schema: schema };
31
35
  }
32
36
 
33
37
  export function validateInvokeArgs(schema, args) {
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionKernelTools, KernelToolInvokeOptions, KernelToolInvokeScope } from "@code-yeongyu/senpi";
2
+ import type { EvalLanguage } from "../../tool/types.ts";
2
3
  import type { KernelToolErrorCode, KernelToolHostDenial, KernelToolHostDenialReason } from "./kernel-tools-errors.ts";
3
4
 
4
5
  export type { KernelToolErrorCode, KernelToolHostDenial, KernelToolHostDenialReason };
@@ -7,7 +8,7 @@ export type KernelToolDescriptor = {
7
8
  readonly name: string;
8
9
  readonly description: string;
9
10
  readonly input_schema: unknown;
10
- readonly language: "js";
11
+ readonly language: EvalLanguage;
11
12
  readonly kernel_generation: number;
12
13
  readonly definition_revision: number;
13
14
  };
@@ -9,6 +9,54 @@ const FUNCTION_BYTES = 64;
9
9
  const INTERNAL_GLOBAL_PREFIX = "__senpi";
10
10
  const MIN_REPORTED_BYTES = 1024 * 1024;
11
11
 
12
+ function intrinsicGetter(prototype, name) {
13
+ return Object.getOwnPropertyDescriptor(prototype, name)?.get;
14
+ }
15
+
16
+ // Built-in getters captured at load: a user subclass that overrides `byteLength` or `size` is never called.
17
+ const TYPED_ARRAY_BYTE_LENGTH = intrinsicGetter(Object.getPrototypeOf(Uint8Array.prototype), "byteLength");
18
+ const DATA_VIEW_BYTE_LENGTH = intrinsicGetter(DataView.prototype, "byteLength");
19
+ const ARRAY_BUFFER_BYTE_LENGTH = intrinsicGetter(ArrayBuffer.prototype, "byteLength");
20
+ const SHARED_ARRAY_BUFFER_BYTE_LENGTH =
21
+ typeof SharedArrayBuffer === "function" ? intrinsicGetter(SharedArrayBuffer.prototype, "byteLength") : undefined;
22
+ const MAP_SIZE = intrinsicGetter(Map.prototype, "size");
23
+ const SET_SIZE = intrinsicGetter(Set.prototype, "size");
24
+ const BLOB_SIZE = typeof Blob === "function" ? intrinsicGetter(Blob.prototype, "size") : undefined;
25
+ // Iteration captured at load too: a user who replaces Map.prototype.entries or an iterator's next() is never called.
26
+ const MAP_ENTRIES = Map.prototype.entries;
27
+ const SET_VALUES = Set.prototype.values;
28
+ const MAP_ITERATOR_NEXT = Object.getPrototypeOf(new Map().entries()).next;
29
+ const SET_ITERATOR_NEXT = Object.getPrototypeOf(new Set().values()).next;
30
+
31
+ function read(getter, value) {
32
+ return Reflect.apply(getter, value, []);
33
+ }
34
+
35
+ const GET_PROTOTYPE_OF = Object.getPrototypeOf;
36
+ const BLOB_PROTOTYPE = typeof Blob === "function" ? Blob.prototype : undefined;
37
+
38
+ function inheritsFromBlob(value) {
39
+ for (let prototype = GET_PROTOTYPE_OF(value); prototype !== null; prototype = GET_PROTOTYPE_OF(prototype)) {
40
+ // A Proxy in the chain would run its getPrototypeOf trap on the next step.
41
+ if (types.isProxy(prototype)) return false;
42
+ if (prototype === BLOB_PROTOTYPE) return true;
43
+ }
44
+ return false;
45
+ }
46
+
47
+ // The Blob brand: a cheap prototype-chain pre-check (no user hook; proxies never reach here) so ordinary
48
+ // objects skip the try, then the size getter as the proof, which throws on a forged look-alike.
49
+ // instanceof would call a user-redefinable Symbol.hasInstance.
50
+ function isBlob(value) {
51
+ if (BLOB_SIZE === undefined || !inheritsFromBlob(value)) return false;
52
+ try {
53
+ read(BLOB_SIZE, value);
54
+ return true;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
12
60
  export function captureGlobalBaseline() {
13
61
  return new Set(Object.getOwnPropertyNames(globalThis));
14
62
  }
@@ -55,11 +103,12 @@ function createSizer() {
55
103
  return (total / SAMPLED_ELEMENTS) * length;
56
104
  }
57
105
 
58
- function entriesOf(iterable, count, depth, sizeOf) {
106
+ function entriesOf(iterator, next, count, depth, sizeOf) {
59
107
  const taken = [];
60
- for (const entry of iterable) {
61
- taken.push(entry);
62
- if (taken.length >= SAMPLED_ELEMENTS) break;
108
+ while (taken.length < SAMPLED_ELEMENTS) {
109
+ const step = Reflect.apply(next, iterator, []);
110
+ if (step.done) break;
111
+ taken.push(step.value);
63
112
  }
64
113
  if (taken.length < count) approximate = true;
65
114
  let total = 0;
@@ -67,21 +116,42 @@ function createSizer() {
67
116
  return total * (count / Math.max(1, taken.length));
68
117
  }
69
118
 
119
+ // An own data element, never a getter: an accessor is left unsized and makes the estimate approximate.
120
+ function elementOf(array, index) {
121
+ const descriptor = Object.getOwnPropertyDescriptor(array, index);
122
+ if (descriptor === undefined) return undefined;
123
+ if ("value" in descriptor) return descriptor.value;
124
+ approximate = true;
125
+ return undefined;
126
+ }
127
+
70
128
  function objectSize(value, depth) {
71
129
  if (types.isProxy(value)) return OBJECT_BYTES;
72
- if (ArrayBuffer.isView(value)) return value.byteLength;
73
- if (types.isAnyArrayBuffer(value)) return value.byteLength;
74
- if (typeof Blob === "function" && value instanceof Blob) return value.size;
75
- if (Array.isArray(value)) return OBJECT_BYTES + value.length * POINTER_BYTES + sampled(value.length, (index) => value[index], depth);
76
- if (value instanceof Map) {
77
- const entries = Map.prototype.entries.call(value);
78
- return OBJECT_BYTES + value.size * 2 * POINTER_BYTES + entriesOf(entries, value.size, depth, ([key, item], at) => size(key, at) + size(item, at));
130
+ if (types.isTypedArray(value)) return read(TYPED_ARRAY_BYTE_LENGTH, value);
131
+ if (types.isDataView(value)) return read(DATA_VIEW_BYTE_LENGTH, value);
132
+ if (types.isArrayBuffer(value)) return read(ARRAY_BUFFER_BYTE_LENGTH, value);
133
+ if (types.isSharedArrayBuffer(value) && SHARED_ARRAY_BUFFER_BYTE_LENGTH) return read(SHARED_ARRAY_BUFFER_BYTE_LENGTH, value);
134
+ if (isBlob(value)) return read(BLOB_SIZE, value);
135
+ if (Array.isArray(value)) {
136
+ const length = value.length;
137
+ return OBJECT_BYTES + length * POINTER_BYTES + sampled(length, (index) => elementOf(value, index), depth);
138
+ }
139
+ if (types.isMap(value)) {
140
+ const count = read(MAP_SIZE, value);
141
+ const entries = Reflect.apply(MAP_ENTRIES, value, []);
142
+ return OBJECT_BYTES + count * 2 * POINTER_BYTES + entriesOf(entries, MAP_ITERATOR_NEXT, count, depth, ([key, item], at) => size(key, at) + size(item, at));
143
+ }
144
+ if (types.isSet(value)) {
145
+ const count = read(SET_SIZE, value);
146
+ return OBJECT_BYTES + count * POINTER_BYTES + entriesOf(Reflect.apply(SET_VALUES, value, []), SET_ITERATOR_NEXT, count, depth, size);
79
147
  }
80
- if (value instanceof Set) return OBJECT_BYTES + value.size * POINTER_BYTES + entriesOf(Set.prototype.values.call(value), value.size, depth, size);
81
148
  const keys = Object.keys(value);
82
149
  const propertyValue = (index) => {
83
150
  const descriptor = Object.getOwnPropertyDescriptor(value, keys[index]);
84
- return descriptor !== undefined && "value" in descriptor ? descriptor.value : undefined;
151
+ if (descriptor === undefined) return undefined;
152
+ if ("value" in descriptor) return descriptor.value;
153
+ approximate = true;
154
+ return undefined;
85
155
  };
86
156
  return OBJECT_BYTES + keys.length * POINTER_BYTES + sampled(keys.length, propertyValue, depth);
87
157
  }
@@ -114,7 +184,7 @@ function createSizer() {
114
184
  try {
115
185
  return { bytes: POINTER_BYTES + size(value, 0), approximate };
116
186
  } catch (error) {
117
- // A user value can still throw from an exotic element read (an index getter); it is left unsized.
187
+ // A forged built-in look-alike (a Blob-prototype object without its slot) throws from the intrinsic getter; it is left unsized.
118
188
  if (error instanceof Error) return undefined;
119
189
  throw error;
120
190
  }
@@ -8,6 +8,7 @@ import { awaitMaybePromise, indirectEval, wrapUserCode } from "./worker-indirect
8
8
  import { installShellCapture } from "./worker-shell-capture.js";
9
9
  import { bindKernelBun } from "./worker-webview.js";
10
10
  import { createWorkpool } from "./workpool.js";
11
+ import { createHandleHelpers } from "./handles.js";
11
12
  import { inKernelToolInvoke } from "./kernel-tools-context.js";
12
13
  import { kernelToolError } from "./kernel-tools-errors.js";
13
14
  import { createKernelToolRegistry, createToolNamespace } from "./kernel-tools-registry.js";
@@ -137,10 +138,18 @@ export class JsWorkerRuntime {
137
138
  globalThis.workpool = (agent, name, options) => createWorkpool((toolName, args) => this.#callTool(toolName, args), agent, name, options);
138
139
  globalThis.parallel = async thunks => await this.#parallel(thunks);
139
140
  globalThis.pipeline = async (items, ...stages) => await this.#pipeline(items, stages);
140
- globalThis.completion = async (prompt, opts) => await this.#callTool("completion", { prompt, opts });
141
+ const handles = createHandleHelpers(async (toolName, args) => await this.#callTool(toolName, args));
142
+ globalThis.completion = async (prompt, opts) => {
143
+ const value = await this.#callTool("completion", { prompt, opts });
144
+ // The host answers a {handle: true} request with a saved reference; the cell gets the control view.
145
+ return isPlainObject(opts) && opts.handle === true ? handles.handle(value) : value;
146
+ };
147
+ globalThis.wait = async (list, options) => await handles.wait(list, options);
148
+ globalThis.handle = value => handles.handle(value);
141
149
  globalThis.tool = createToolNamespace(
142
150
  (fn, metadata) => this.#tools.define(fn, metadata),
143
151
  async (name, args) => await this.#callTool(name, args),
152
+ { defined: () => this.#tools.defined(), undefine: (name) => this.#tools.undefine(name) },
144
153
  );
145
154
  globalThis.tools = globalThis.tool;
146
155
  const originalLog = console.log.bind(console);