@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
@@ -22,6 +22,13 @@ export interface KernelSpawnOptions {
22
22
 
23
23
  export type KernelSpawnProcess = (options: KernelSpawnOptions) => KernelChild;
24
24
 
25
+ /** Signals a kernel's whole process group (addressed by its leader pid); throws like `process.kill`. */
26
+ export type KillProcessGroup = (pid: number, signal: NodeJS.Signals) => void;
27
+
28
+ export const killProcessGroup: KillProcessGroup = (pid, signal) => {
29
+ process.kill(-pid, signal);
30
+ };
31
+
25
32
  export class PythonKernelRetirementError extends Error {
26
33
  constructor(pid: number | undefined) {
27
34
  super(`Python kernel process${pid === undefined ? "" : ` ${pid}`} did not exit after SIGKILL`);
@@ -95,10 +102,10 @@ export async function waitForExit(child: KernelChild, timeoutMs: number): Promis
95
102
  // A cell's own subprocess is spawned into the detached kernel's process group. When the
96
103
  // leader exits gracefully on close, kill the group so a Popen the cell left running does
97
104
  // not outlive the kernel; the group is addressed by the leader pid (== pgid) while members live.
98
- export function sweepProcessGroup(child: KernelChild): void {
105
+ export function sweepProcessGroup(child: KernelChild, killGroup: KillProcessGroup = killProcessGroup): void {
99
106
  if (child.pid === undefined || process.platform === "win32") return;
100
107
  try {
101
- process.kill(-child.pid, "SIGKILL");
108
+ killGroup(child.pid, "SIGKILL");
102
109
  } catch (error) {
103
110
  // ESRCH means the group is already gone (the success case). Any other error
104
111
  // (for example EPERM on a member we cannot signal) must not fail a graceful close.
@@ -106,7 +113,11 @@ export function sweepProcessGroup(child: KernelChild): void {
106
113
  }
107
114
  }
108
115
 
109
- export async function hardKill(child: KernelChild, timeoutMs: number): Promise<void> {
116
+ export async function hardKill(
117
+ child: KernelChild,
118
+ timeoutMs: number,
119
+ killGroup: KillProcessGroup = killProcessGroup,
120
+ ): Promise<void> {
110
121
  await new Promise<void>((resolve, reject) => {
111
122
  let timer: NodeJS.Timeout | undefined;
112
123
  let settled = false;
@@ -123,7 +134,7 @@ export async function hardKill(child: KernelChild, timeoutMs: number): Promise<v
123
134
  let signalDelivered = true;
124
135
  if (child.pid !== undefined && process.platform !== "win32") {
125
136
  try {
126
- process.kill(-child.pid, "SIGKILL");
137
+ killGroup(child.pid, "SIGKILL");
127
138
  } catch (error) {
128
139
  if (!(error instanceof Error)) {
129
140
  settle(new Error(String(error)));
@@ -1,10 +1,20 @@
1
1
  import type { KernelToHostMessage } from "../../bridge/protocol.ts";
2
2
 
3
- // Fresh-cache Windows bootstrap: p99 5,220 ms across 30 samples in Actions run
4
- // 36882163342. Twice that observation, rounded up to a second, is a hang guard,
5
- // not a total startup budget: only an advancing kernel stage rearms it.
3
+ /**
4
+ * How long a starting interpreter may stay completely still before startup fails: no new stage, no output
5
+ * line, and no CPU used. A cold, contended interpreter importing the stdlib emits nothing for seconds but
6
+ * keeps the CPU busy, so it never trips; only one that is silent AND idle does. Fresh-cache Windows
7
+ * bootstraps measured p99 5,220 ms of mostly import time (Actions run 36882163342), so 11 s of complete
8
+ * stillness is a stall, not a slow start.
9
+ */
6
10
  export const pythonStartupHangGuardMs = 11_000;
7
11
 
12
+ /**
13
+ * Total startup budget, a backstop for an interpreter that keeps burning CPU without ever reaching
14
+ * `ready` (a livelock). Over twenty times the slowest measured cold start; never reached by a real one.
15
+ */
16
+ export const pythonStartupCeilingMs = 120_000;
17
+
8
18
  const stages = ["interpreter-launch", "stdlib-imports", "runtime-init", "host-init"] as const;
9
19
  export type PythonStartupStage = (typeof stages)[number];
10
20
 
@@ -18,19 +28,30 @@ export class PythonKernelStartupError extends Error {
18
28
  }
19
29
  }
20
30
 
21
- /** Owns the ready event and a per-stage inactivity watchdog, never a total deadline. */
31
+ export interface PythonStartupOptions {
32
+ readonly noProgressMs: number;
33
+ readonly ceilingMs: number;
34
+ readonly failureDetail: () => string;
35
+ /** CPU used so far by the interpreter (any unit that only grows); `undefined` when unreadable. */
36
+ readonly readCpuTime: () => bigint | undefined;
37
+ }
38
+
22
39
  export class PythonStartup {
23
- readonly #timeoutMs: number;
24
- readonly #failureDetail: () => string;
40
+ readonly #options: PythonStartupOptions;
25
41
  readonly #ready = Promise.withResolvers<void>();
26
42
  readonly ready = this.#ready.promise;
43
+ readonly #ceiling: NodeJS.Timeout;
27
44
  #timer: NodeJS.Timeout | undefined;
28
45
  #stageIndex = 0;
46
+ #lastCpu: bigint | undefined;
29
47
  #settled = false;
30
48
 
31
- constructor(timeoutMs: number, failureDetail: () => string) {
32
- this.#timeoutMs = timeoutMs;
33
- this.#failureDetail = failureDetail;
49
+ constructor(options: PythonStartupOptions) {
50
+ this.#options = options;
51
+ this.#ceiling = setTimeout(
52
+ () => this.#fail(`not ready after ${Math.round(options.ceilingMs / 1000)} s`),
53
+ options.ceilingMs,
54
+ );
34
55
  this.#arm();
35
56
  }
36
57
 
@@ -45,31 +66,48 @@ export class PythonStartup {
45
66
  return stage;
46
67
  }
47
68
 
69
+ activity(): void {
70
+ if (!this.#settled) this.#arm();
71
+ }
72
+
48
73
  settle(error?: Error): boolean {
49
74
  if (this.#settled) return false;
50
75
  this.#settled = true;
51
76
  clearTimeout(this.#timer);
77
+ clearTimeout(this.#ceiling);
52
78
  if (error) {
53
- const stage = stages[this.#stageIndex] ?? "interpreter-launch";
54
79
  this.#ready.reject(
55
80
  error instanceof PythonKernelStartupError
56
81
  ? error
57
- : new PythonKernelStartupError(stage, error.message, error),
82
+ : new PythonKernelStartupError(this.#stage(), error.message, error),
58
83
  );
59
84
  } else this.#ready.resolve();
60
85
  return true;
61
86
  }
62
87
 
63
88
  #arm(): void {
89
+ this.#lastCpu = this.#options.readCpuTime();
64
90
  clearTimeout(this.#timer);
65
- this.#timer = setTimeout(() => {
66
- const detail = this.#failureDetail().trim();
67
- this.settle(
68
- new PythonKernelStartupError(
69
- stages[this.#stageIndex] ?? "interpreter-launch",
70
- `no progress for ${this.#timeoutMs}ms${detail ? `; ${detail}` : ""}`,
71
- ),
72
- );
73
- }, this.#timeoutMs);
91
+ this.#timer = setTimeout(() => this.#expire(), this.#options.noProgressMs);
92
+ }
93
+
94
+ #expire(): void {
95
+ if (this.#settled) return;
96
+ const cpu = this.#options.readCpuTime();
97
+ // Where CPU can't be read, stage and output are the only signs of life, as before.
98
+ if (cpu !== undefined && cpu !== this.#lastCpu) {
99
+ this.#arm();
100
+ return;
101
+ }
102
+ this.#fail(`no output, stage change or CPU use for ${this.#options.noProgressMs}ms`);
103
+ }
104
+
105
+ #fail(reason: string): void {
106
+ const detail = this.#options.failureDetail().trim();
107
+ this.settle(new PythonKernelStartupError(this.#stage(), `${reason}${detail ? `; ${detail}` : ""}`));
108
+ }
109
+
110
+ #stage(): PythonStartupStage {
111
+ return stages[this.#stageIndex] ?? "interpreter-launch";
74
112
  }
75
113
  }
@@ -11,6 +11,7 @@ import {
11
11
  } from "../../bridge/protocol.ts";
12
12
  import { applySessionEnvironment, type SessionEnvironment } from "../session-env.ts";
13
13
  import type { KernelPreludePlan } from "../shared/kernel-prelude-plan.ts";
14
+ import { readKernelCpuTime } from "../shared/process-cpu.ts";
14
15
  import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
15
16
  import {
16
17
  defaultSpawn,
@@ -18,6 +19,7 @@ import {
18
19
  type KernelChild,
19
20
  type KernelSpawnOptions,
20
21
  type KernelSpawnProcess,
22
+ type KillProcessGroup,
21
23
  numberOrNull,
22
24
  signalOrNull,
23
25
  splitCommand,
@@ -44,8 +46,13 @@ export interface PythonTransportOptions {
44
46
  /** Per-session PI_* values merged into the interpreter environment at spawn. */
45
47
  readonly sessionEnv?: SessionEnvironment;
46
48
  readonly startupTimeoutMs: number;
49
+ readonly startupCeilingMs: number;
50
+ readonly readCpuTime?: (pid: number | undefined) => bigint | undefined;
51
+ readonly killProcessGroup?: KillProcessGroup;
47
52
  readonly onStartupProgress?: (stage: PythonStartupStage) => void;
48
53
  readonly memory?: KernelMemoryThresholds;
54
+ /** Kernel-tool descriptors carry this; a restarted interpreter gets a new one, so old descriptors go stale. */
55
+ readonly kernelGeneration?: number;
49
56
  readonly onMessage?: (message: KernelToHostMessage) => void;
50
57
  readonly spawnProcess?: KernelSpawnProcess;
51
58
  readonly isOwned: () => boolean;
@@ -140,6 +147,11 @@ export class PythonKernelTransport {
140
147
  return transport;
141
148
  }
142
149
 
150
+ /** Kernel-tool frames for the runner's control reader (served even while a cell runs). */
151
+ post(message: HostToKernelMessage): void {
152
+ if (this.#active && !this.#exited) this.#write(message);
153
+ }
154
+
143
155
  run(input: PythonTransportRunInput): void {
144
156
  const preludes = input.preludePlan && {
145
157
  install: input.preludePlan.install.map(({ exports, python }) => ({ exports: [...exports], python })),
@@ -166,7 +178,7 @@ export class PythonKernelTransport {
166
178
  return;
167
179
  }
168
180
  if (!this.#active) {
169
- await hardKill(this.#child, hardKillWaitMs);
181
+ await hardKill(this.#child, hardKillWaitMs, this.#options.killProcessGroup);
170
182
  return;
171
183
  }
172
184
  this.#active = false;
@@ -178,15 +190,15 @@ export class PythonKernelTransport {
178
190
  }
179
191
  // hardKill kills the whole group; a graceful leader exit does not, so sweep it
180
192
  // to retire any subprocess the cell left running in the kernel's process group.
181
- if (await exited) sweepProcessGroup(this.#child);
182
- else await hardKill(this.#child, hardKillWaitMs);
193
+ if (await exited) sweepProcessGroup(this.#child, this.#options.killProcessGroup);
194
+ else await hardKill(this.#child, hardKillWaitMs, this.#options.killProcessGroup);
183
195
  }
184
196
 
185
197
  retire(): Promise<void> {
186
198
  if (this.#exited || this.#isGone) return Promise.resolve();
187
199
  if (this.#retirement) return this.#retirement;
188
200
  this.#active = false;
189
- const retirement = hardKill(this.#child, hardKillWaitMs).finally(() => {
201
+ const retirement = hardKill(this.#child, hardKillWaitMs, this.#options.killProcessGroup).finally(() => {
190
202
  this.#detachListeners();
191
203
  if (this.#retirement === retirement) this.#retirement = null;
192
204
  });
@@ -195,7 +207,16 @@ export class PythonKernelTransport {
195
207
  }
196
208
 
197
209
  async #initialize(): Promise<void> {
198
- const startup = new PythonStartup(this.#options.startupTimeoutMs, () => this.#stderrTail);
210
+ const pid = this.#child.pid;
211
+ const startup = new PythonStartup({
212
+ noProgressMs: this.#options.startupTimeoutMs,
213
+ ceilingMs: this.#options.startupCeilingMs,
214
+ failureDetail: () => this.#stderrTail,
215
+ readCpuTime: () => {
216
+ if (this.#options.readCpuTime) return this.#options.readCpuTime(pid);
217
+ return pid === undefined ? undefined : readKernelCpuTime(pid);
218
+ },
219
+ });
199
220
  this.#startup = startup;
200
221
  const onStdout = (chunk: unknown) => this.#onStdout(String(chunk));
201
222
  const onStderr = (chunk: unknown) => this.#onStderr(String(chunk));
@@ -211,8 +232,14 @@ export class PythonKernelTransport {
211
232
  this.#child.stderr.on("data", onStderr);
212
233
  this.#child.on("error", onError);
213
234
  this.#child.on("exit", onExit);
214
- const { sessionId, connection, memory } = this.#options;
215
- this.#write({ type: "init", sessionId, connection, ...(memory === undefined ? {} : { memory }) });
235
+ const { sessionId, connection, memory, kernelGeneration } = this.#options;
236
+ this.#write({
237
+ type: "init",
238
+ sessionId,
239
+ connection,
240
+ ...(memory === undefined ? {} : { memory }),
241
+ ...(kernelGeneration === undefined ? {} : { kernelGeneration }),
242
+ });
216
243
  await startup.ready;
217
244
  }
218
245
 
@@ -234,6 +261,7 @@ export class PythonKernelTransport {
234
261
 
235
262
  #onStderr(chunk: string): void {
236
263
  if (!this.#active) return;
264
+ this.#startup?.activity();
237
265
  this.#stderrTail = `${this.#stderrTail}${chunk}`.slice(-4_000);
238
266
  this.#options.onMessage?.({ type: "text", stream: "stderr", data: chunk });
239
267
  }
@@ -251,6 +279,7 @@ export class PythonKernelTransport {
251
279
  if (stage !== undefined) this.#options.onStartupProgress?.(stage);
252
280
  return;
253
281
  }
282
+ if (message.type === "text") this.#startup?.activity();
254
283
  if (message.type === "ready") this.#settleStartup();
255
284
  else if (message.type === "init-failed") this.#settleStartup(new Error(message.error.message));
256
285
  else if (message.type === "result") this.#options.onResult(this, message);
@@ -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
@@ -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
 
@@ -165,27 +165,27 @@ class SenpiGlobalSizer
165
165
  return ObjectSpace.memsize_of(value) if Integer === value || Float === value
166
166
  return 0 if @seen.key?(value)
167
167
  @seen[value] = true
168
- return SENPI_SIZER_OBJECT + SENPI_STRING_BYTESIZE.bind_call(value) if String === value
168
+ return SENPI_SIZER_OBJECT + SENPI_STRING_BYTESIZE.bind(value).call() if String === value
169
169
  if depth >= SENPI_SIZER_MAX_DEPTH || @nodes >= SENPI_SIZER_NODE_BUDGET
170
170
  @approximate = true
171
171
  return ObjectSpace.memsize_of(value)
172
172
  end
173
173
  if Array === value
174
- length = SENPI_ARRAY_SIZE.bind_call(value)
175
- SENPI_SIZER_OBJECT + length * SENPI_SIZER_POINTER + sampled(length, depth) { |index| SENPI_ARRAY_AT.bind_call(value, index) }
174
+ length = SENPI_ARRAY_SIZE.bind(value).call()
175
+ SENPI_SIZER_OBJECT + length * SENPI_SIZER_POINTER + sampled(length, depth) { |index| SENPI_ARRAY_AT.bind(value).call(index) }
176
176
  elsif Hash === value
177
177
  hash_size(value, depth)
178
178
  else
179
- ivars = SENPI_IVARS.bind_call(value)
180
- ObjectSpace.memsize_of(value) + sampled(ivars.length, depth) { |index| SENPI_IVAR_GET.bind_call(value, ivars[index]) }
179
+ ivars = SENPI_IVARS.bind(value).call()
180
+ ObjectSpace.memsize_of(value) + sampled(ivars.length, depth) { |index| SENPI_IVAR_GET.bind(value).call(ivars[index]) }
181
181
  end
182
182
  end
183
183
 
184
184
  def hash_size(hash, depth)
185
- count = SENPI_HASH_SIZE.bind_call(hash)
185
+ count = SENPI_HASH_SIZE.bind(hash).call()
186
186
  taken = 0
187
187
  total = 0
188
- SENPI_HASH_EACH.bind_call(hash) do |key, item|
188
+ SENPI_HASH_EACH.bind(hash).call() do |key, item|
189
189
  total += size(key, depth + 1) + size(item, depth + 1)
190
190
  taken += 1
191
191
  break if taken >= SENPI_SIZER_SAMPLE
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Mario Zechner
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,42 @@
1
+ # Vendored: @earendil-works/pi-codemode 1.0.1
2
+
3
+ - Upstream: https://github.com/earendil-works/pi, directory `packages/codemode/src`
4
+ - Tag `v1.0.1`, commit `a7229ddc21810d6245105978033b7df645ecc2f7` (`packages/codemode/package.json` version 1.0.1)
5
+ - License: MIT, upstream `LICENSE` copied unchanged beside this file.
6
+ - Why vendored: the published package ships compiled `dist` only, and senpi needs three host changes that are not in any release (listed below once they land). Vendoring the source keeps them in this repository, so a registry consumer receives them.
7
+
8
+ ## Provenance
9
+
10
+ Building `packages/codemode` at that commit with its pinned compiler (`typescript` 7.0.2, `tsc -p tsconfig.build.json`) reproduces all 40 files of the published npm `@earendil-works/pi-codemode@1.0.1` `dist` byte for byte, so this source is the released code.
11
+
12
+ Each file below starts with a two-line attribution header. `wasm.ts` and `identifier.ts` are otherwise the upstream file unchanged. The other five carry the local changes listed below, and nothing else: outside the `senpi-change` blocks and the lines marked `// senpi-change`, every line is upstream's, in upstream's order. The SHA-256 is of the upstream file.
13
+
14
+ | file | upstream SHA-256 |
15
+ | --- | --- |
16
+ | `runtime/host.ts` | `b81e1bb07f3cd7e612c5ed03b6d23929acf3738be764c0741b85306d8221c522` |
17
+ | `runtime/worker.ts` | `f2e4854de6c9713390148a7d02b35e753065aca42c759ca14676f90f937f0a8a` |
18
+ | `runtime/prelude-source.ts` | `bd544106d5bd36543be4e5fc21128c9cd46b26856fb0b17931250b955f93c24a` |
19
+ | `runtime/protocol.ts` | `b5150df8e6c743f99a94f13cf6f4ae7515768bb9e6305e1a7e4dacd20e4dc6ff` |
20
+ | `types.ts` | `a54fd8d97468bedd16cc1aae6d89648581da90bd55f65bb0b116c947893b5920` |
21
+ | `wasm.ts` | `0785024745568c86611b00a82f2bc8f70fc1559eff7200e64a449ebbeeeebb94` |
22
+ | `identifier.ts` | `c920a297e79186edc61348906950b639e8d67602122f165fb98012fd32be2f11` |
23
+
24
+ Only these seven files are vendored: the closure of `runtime/host.ts` (the sandbox host). `index.ts`, `source.ts` and `declarations.ts` are not used.
25
+
26
+ ## Local changes
27
+
28
+ Every change is marked, in one of two ways, so a sync can find each one:
29
+
30
+ - a block between `// senpi-change begin: <topic>` and `// senpi-change end`, which adds code or replaces the upstream lines it stands in place of (for example `types.ts`'s `CodemodeResult`, and the end of `finish()` in `runtime/host.ts`);
31
+ - a single upstream line edited in place, ending in `// senpi-change` (an extra import name, the `"output-frame"` message type in `isWorkerToHostMessage`, the prelude's `optionsJson` parameter and its stream-mode output-cap condition).
32
+
33
+ A sync must re-apply both kinds: dropping a marked line (for example `"output-frame"` in the protocol guard) would make the host ignore every frame. Each one is an option whose default is upstream's behaviour: with the defaults, the ported upstream suite (`test/sandbox/vendor/upstream-sandbox.test.ts`, 47 cases, only its imports changed) passes unchanged.
34
+
35
+ 1. **Output streaming** (`output: "stream"`, `onOutputFrame`, `windowBytes`, `frameBytes`; `types.ts`, `runtime/protocol.ts`, `runtime/worker.ts`, `runtime/host.ts`, `runtime/prelude-source.ts`). Upstream keeps every output item until the script settles, which is why it caps output at 16 Mi characters. In stream mode:
36
+ - The host keeps nothing; `result.output` stays empty and `result.streamed` reports frames and the peak bytes in flight.
37
+ - The worker splits each item into frames of at most `frameBytes` (default 64 KiB), never splitting a surrogate pair, tagged with item id, sequence and a final flag.
38
+ - It sends a frame only after taking that many bytes from a shared credit counter (default window 256 KiB). It sleeps on the counter with `Atomics.wait` while the window is full, and re-checks the interrupt flag each time it wakes.
39
+ - The host hands frames to the consumer one at a time, in order, and returns their credit only once the consumer has finished. The output cap does not apply, because memory is bounded by the window.
40
+ - A normal finish settles only after every frame is consumed. An abort or timeout settles at once and drops frames still queued. The timer and the abort signal stay armed while a finished run's last frames drain, so a late abort, timeout or `close()` abandons them too (the run settles with that error): a consumer that never returns cannot keep a run alive.
41
+ - `frameBytes` and `windowBytes` must be at least 4, so a frame can always hold a whole surrogate pair.
42
+ 2. **Builtin store policy** (`builtins: { store: "collect" | "reject" }`; `types.ts`, `runtime/protocol.ts`, `runtime/worker.ts`, `runtime/host.ts`, `runtime/prelude-source.ts`). With `"reject"`, `load()` returns `undefined` and `store()` throws `CodemodeStoreDisabledError`. The names stay defined, so scripts written for upstream fail with a clear message instead of a ReferenceError.
@@ -0,0 +1,14 @@
1
+ // Vendored from https://github.com/earendil-works/pi (packages/codemode/src) at v1.0.1, commit a7229ddc21810d6245105978033b7df645ecc2f7.
2
+ // MIT license; see LICENSE beside this file. Local changes are listed in VENDORED.md.
3
+ /**
4
+ * The identifier a script uses for a tool: characters that are not valid in a JavaScript
5
+ * identifier become `_`. `mcp__docs__search` stays as is, `my-tool` becomes `my_tool`.
6
+ */
7
+ export function toCodemodeIdentifier(name: string): string {
8
+ let identifier = "";
9
+ for (const char of name) {
10
+ const valid = identifier === "" ? /^[A-Za-z_$]$/.test(char) : /^[A-Za-z0-9_$]$/.test(char);
11
+ identifier += valid ? char : "_";
12
+ }
13
+ return identifier === "" ? "_" : identifier;
14
+ }