@code-yeongyu/senpi-codemode 2026.10.7 → 2026.10.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +67 -5
  3. package/package.json +5 -4
  4. package/src/bridge/http-server.ts +9 -1
  5. package/src/bridge/kernel-tools-protocol.ts +8 -0
  6. package/src/bridge/protocol.ts +4 -0
  7. package/src/bridges/eval-virtual-schemas.ts +22 -0
  8. package/src/environments/editable-check.ts +116 -0
  9. package/src/environments/install-lock.ts +223 -0
  10. package/src/environments/py-environment.ts +98 -0
  11. package/src/environments/py-installer.ts +247 -0
  12. package/src/environments/python-environments.ts +85 -0
  13. package/src/environments/revision-store.ts +110 -0
  14. package/src/extension/bridge-tool-call.ts +2 -0
  15. package/src/extension/cell-kernel-tools.ts +28 -0
  16. package/src/extension/kernel-registration.ts +5 -0
  17. package/src/extension/kernel-replacement.ts +20 -0
  18. package/src/extension/runtime-factory.ts +12 -0
  19. package/src/extension/session-manager-proxy.ts +6 -0
  20. package/src/extension/session-manager.ts +24 -2
  21. package/src/index.ts +1 -0
  22. package/src/kernels/jl/prelude.jl +6 -1
  23. package/src/kernels/js/context-manager.ts +15 -1
  24. package/src/kernels/js/kernel-tools-errors.js +2 -0
  25. package/src/kernels/js/kernel-tools-errors.ts +2 -0
  26. package/src/kernels/js/local-module-loader.ts +9 -4
  27. package/src/kernels/js/worker-runtime.js +3 -1
  28. package/src/kernels/js/workpool.js +5 -2
  29. package/src/kernels/py/kernel-contract.ts +11 -2
  30. package/src/kernels/py/kernel-tools-host.ts +97 -0
  31. package/src/kernels/py/kernel.ts +48 -10
  32. package/src/kernels/py/prelude.py +771 -18
  33. package/src/kernels/py/process.ts +15 -4
  34. package/src/kernels/py/startup.ts +58 -20
  35. package/src/kernels/py/transport.ts +49 -8
  36. package/src/kernels/rb/workpool.rb +7 -1
  37. package/src/kernels/sandbox/vendor/pi-codemode/LICENSE +21 -0
  38. package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +42 -0
  39. package/src/kernels/sandbox/vendor/pi-codemode/identifier.ts +14 -0
  40. package/src/kernels/sandbox/vendor/pi-codemode/runtime/host.ts +514 -0
  41. package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +431 -0
  42. package/src/kernels/sandbox/vendor/pi-codemode/runtime/protocol.ts +61 -0
  43. package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +231 -0
  44. package/src/kernels/sandbox/vendor/pi-codemode/types.ts +185 -0
  45. package/src/kernels/sandbox/vendor/pi-codemode/wasm.ts +37 -0
  46. package/src/kernels/shared/cell-source-at-start.ts +18 -0
  47. package/src/kernels/shared/kernel-tools-pump.ts +6 -0
  48. package/src/kernels/shared/process-cpu.ts +69 -0
  49. package/src/kernels/shared/subprocess-queue.ts +1 -0
  50. package/src/tool/cell-handler.ts +1 -1
  51. package/src/tool/eval-tool-options.ts +3 -0
  52. package/src/tool/load-cell.ts +73 -0
  53. package/src/tool/magic-cell-host.ts +65 -0
  54. package/src/tool/magic-cells.ts +65 -0
  55. package/src/tool/run-eval-cell.ts +30 -0
  56. package/src/tool/types.ts +27 -0
@@ -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,
@@ -33,6 +35,9 @@ export interface PythonTransportRunInput {
33
35
  readonly code: string;
34
36
  readonly timeoutMs?: number;
35
37
  readonly preludePlan?: KernelPreludePlan;
38
+ readonly envRoot?: () => string;
39
+ readonly sourceFile?: string;
40
+ readonly bridgeCellToken?: string;
36
41
  }
37
42
 
38
43
  export interface PythonTransportOptions {
@@ -44,8 +49,13 @@ export interface PythonTransportOptions {
44
49
  /** Per-session PI_* values merged into the interpreter environment at spawn. */
45
50
  readonly sessionEnv?: SessionEnvironment;
46
51
  readonly startupTimeoutMs: number;
52
+ readonly startupCeilingMs: number;
53
+ readonly readCpuTime?: (pid: number | undefined) => bigint | undefined;
54
+ readonly killProcessGroup?: KillProcessGroup;
47
55
  readonly onStartupProgress?: (stage: PythonStartupStage) => void;
48
56
  readonly memory?: KernelMemoryThresholds;
57
+ /** Kernel-tool descriptors carry this; a restarted interpreter gets a new one, so old descriptors go stale. */
58
+ readonly kernelGeneration?: number;
49
59
  readonly onMessage?: (message: KernelToHostMessage) => void;
50
60
  readonly spawnProcess?: KernelSpawnProcess;
51
61
  readonly isOwned: () => boolean;
@@ -140,12 +150,26 @@ export class PythonKernelTransport {
140
150
  return transport;
141
151
  }
142
152
 
153
+ /** Kernel-tool frames for the runner's control reader (served even while a cell runs). */
154
+ post(message: HostToKernelMessage): void {
155
+ if (this.#active && !this.#exited) this.#write(message);
156
+ }
157
+
143
158
  run(input: PythonTransportRunInput): void {
144
159
  const preludes = input.preludePlan && {
145
160
  install: input.preludePlan.install.map(({ exports, python }) => ({ exports: [...exports], python })),
146
161
  remove: [...input.preludePlan.remove],
147
162
  };
148
- this.#write({ type: "run", cellId: input.cellId, code: input.code, timeoutMs: input.timeoutMs, preludes });
163
+ this.#write({
164
+ type: "run",
165
+ cellId: input.cellId,
166
+ code: input.code,
167
+ timeoutMs: input.timeoutMs,
168
+ preludes,
169
+ ...(input.envRoot === undefined ? {} : { envRoot: input.envRoot() }),
170
+ ...(input.sourceFile === undefined ? {} : { sourceFile: input.sourceFile }),
171
+ ...(input.bridgeCellToken === undefined ? {} : { bridgeCellToken: input.bridgeCellToken }),
172
+ });
149
173
  }
150
174
 
151
175
  interrupt(reason: string): void {
@@ -166,7 +190,7 @@ export class PythonKernelTransport {
166
190
  return;
167
191
  }
168
192
  if (!this.#active) {
169
- await hardKill(this.#child, hardKillWaitMs);
193
+ await hardKill(this.#child, hardKillWaitMs, this.#options.killProcessGroup);
170
194
  return;
171
195
  }
172
196
  this.#active = false;
@@ -178,15 +202,15 @@ export class PythonKernelTransport {
178
202
  }
179
203
  // hardKill kills the whole group; a graceful leader exit does not, so sweep it
180
204
  // 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);
205
+ if (await exited) sweepProcessGroup(this.#child, this.#options.killProcessGroup);
206
+ else await hardKill(this.#child, hardKillWaitMs, this.#options.killProcessGroup);
183
207
  }
184
208
 
185
209
  retire(): Promise<void> {
186
210
  if (this.#exited || this.#isGone) return Promise.resolve();
187
211
  if (this.#retirement) return this.#retirement;
188
212
  this.#active = false;
189
- const retirement = hardKill(this.#child, hardKillWaitMs).finally(() => {
213
+ const retirement = hardKill(this.#child, hardKillWaitMs, this.#options.killProcessGroup).finally(() => {
190
214
  this.#detachListeners();
191
215
  if (this.#retirement === retirement) this.#retirement = null;
192
216
  });
@@ -195,7 +219,16 @@ export class PythonKernelTransport {
195
219
  }
196
220
 
197
221
  async #initialize(): Promise<void> {
198
- const startup = new PythonStartup(this.#options.startupTimeoutMs, () => this.#stderrTail);
222
+ const pid = this.#child.pid;
223
+ const startup = new PythonStartup({
224
+ noProgressMs: this.#options.startupTimeoutMs,
225
+ ceilingMs: this.#options.startupCeilingMs,
226
+ failureDetail: () => this.#stderrTail,
227
+ readCpuTime: () => {
228
+ if (this.#options.readCpuTime) return this.#options.readCpuTime(pid);
229
+ return pid === undefined ? undefined : readKernelCpuTime(pid);
230
+ },
231
+ });
199
232
  this.#startup = startup;
200
233
  const onStdout = (chunk: unknown) => this.#onStdout(String(chunk));
201
234
  const onStderr = (chunk: unknown) => this.#onStderr(String(chunk));
@@ -211,8 +244,14 @@ export class PythonKernelTransport {
211
244
  this.#child.stderr.on("data", onStderr);
212
245
  this.#child.on("error", onError);
213
246
  this.#child.on("exit", onExit);
214
- const { sessionId, connection, memory } = this.#options;
215
- this.#write({ type: "init", sessionId, connection, ...(memory === undefined ? {} : { memory }) });
247
+ const { sessionId, connection, memory, kernelGeneration } = this.#options;
248
+ this.#write({
249
+ type: "init",
250
+ sessionId,
251
+ connection,
252
+ ...(memory === undefined ? {} : { memory }),
253
+ ...(kernelGeneration === undefined ? {} : { kernelGeneration }),
254
+ });
216
255
  await startup.ready;
217
256
  }
218
257
 
@@ -234,6 +273,7 @@ export class PythonKernelTransport {
234
273
 
235
274
  #onStderr(chunk: string): void {
236
275
  if (!this.#active) return;
276
+ this.#startup?.activity();
237
277
  this.#stderrTail = `${this.#stderrTail}${chunk}`.slice(-4_000);
238
278
  this.#options.onMessage?.({ type: "text", stream: "stderr", data: chunk });
239
279
  }
@@ -251,6 +291,7 @@ export class PythonKernelTransport {
251
291
  if (stage !== undefined) this.#options.onStartupProgress?.(stage);
252
292
  return;
253
293
  }
294
+ if (message.type === "text") this.#startup?.activity();
254
295
  if (message.type === "ready") this.#settleStartup();
255
296
  else if (message.type === "init-failed") this.#settleStartup(new Error(message.error.message));
256
297
  else if (message.type === "result") this.#options.onResult(this, message);
@@ -23,9 +23,15 @@ class SenpiWorkpool
23
23
  end
24
24
  end
25
25
 
26
- def workpool(agent, name, mode: nil)
26
+ def workpool(agent, name, mode: nil, tools: nil)
27
27
  args = { "op" => "create", "agent" => agent, "name" => name }
28
28
  args["mode"] = mode unless mode.nil?
29
+ unless tools.nil?
30
+ unless tools.is_a?(Array) && tools.all? { |name| name.is_a?(String) }
31
+ raise SenpiBridgeError.new("workpool(tools: ...) takes an array of tool names; got #{tools.class}", "invalid_tools")
32
+ end
33
+ args["tools"] = tools.dup
34
+ end
29
35
  result = __senpi_workpool_call(args)
30
36
  details = result["details"]
31
37
  if details.is_a?(Hash)
@@ -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
+ }