@code-yeongyu/senpi-codemode 2026.10.9 → 2026.10.10-3

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 (65) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +131 -5
  3. package/package.json +6 -5
  4. package/src/bridge/protocol.ts +8 -3
  5. package/src/config/executable-settings.json +5 -0
  6. package/src/config/project-trust.ts +51 -0
  7. package/src/config/settings-overrides.ts +2 -0
  8. package/src/environments/js-environments.ts +208 -0
  9. package/src/environments/js-installer.ts +268 -0
  10. package/src/environments/js-revision-files.ts +91 -0
  11. package/src/environments/no-links.ts +132 -0
  12. package/src/environments/project-resolves.ts +54 -0
  13. package/src/environments/revision-store.ts +20 -4
  14. package/src/extension/kernel-registration.ts +7 -2
  15. package/src/extension/runtime-factory.ts +40 -3
  16. package/src/extension/runtime-info.ts +6 -2
  17. package/src/extension/session-manager.ts +16 -2
  18. package/src/index.ts +1 -0
  19. package/src/interpreters/detect.ts +34 -1
  20. package/src/kernels/AGENTS.md +2 -0
  21. package/src/kernels/js/cell-dispatch.ts +44 -0
  22. package/src/kernels/js/context-manager.ts +46 -51
  23. package/src/kernels/js/control-frames.ts +24 -0
  24. package/src/kernels/js/host-entries.ts +62 -0
  25. package/src/kernels/js/kernel-contract.ts +16 -1
  26. package/src/kernels/js/kernel-memory-bridge.ts +1 -1
  27. package/src/kernels/js/kernel-memory.ts +79 -0
  28. package/src/kernels/js/kernel-tools-registry.js +1 -0
  29. package/src/kernels/js/local-module-loader.ts +17 -6
  30. package/src/kernels/js/process-drain-scan.d.ts +8 -0
  31. package/src/kernels/js/process-drain-scan.js +28 -0
  32. package/src/kernels/js/process-entry.js +462 -0
  33. package/src/kernels/js/process-memory-host.ts +83 -0
  34. package/src/kernels/js/process-worker.ts +298 -0
  35. package/src/kernels/js/run-queue.ts +7 -0
  36. package/src/kernels/js/worker-core.js +7 -2
  37. package/src/kernels/js/worker-cwd.js +5 -2
  38. package/src/kernels/js/worker-host.ts +9 -2
  39. package/src/kernels/js/worker-package-resolve.js +153 -0
  40. package/src/kernels/js/worker-runtime.js +1 -0
  41. package/src/kernels/js/worker-slot.ts +9 -0
  42. package/src/kernels/js/worker-startup.ts +49 -1
  43. package/src/kernels/js/worker-webview.js +8 -0
  44. package/src/kernels/py/host-cell.ts +8 -31
  45. package/src/kernels/py/prelude.py +10 -2
  46. package/src/kernels/py/process.ts +4 -0
  47. package/src/kernels/sandbox/sandbox-cell.ts +116 -0
  48. package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +1 -0
  49. package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +14 -1
  50. package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +4 -1
  51. package/src/kernels/sandbox/vendor/pi-codemode/types.ts +2 -0
  52. package/src/kernels/shared/host-cell.ts +57 -0
  53. package/src/kernels/shared/subprocess-process.ts +14 -0
  54. package/src/prompt/eval-prompt.ts +8 -1
  55. package/src/tool/AGENTS.md +1 -0
  56. package/src/tool/cell-handler.ts +9 -11
  57. package/src/tool/eval-request.ts +23 -1
  58. package/src/tool/eval-tool-options.ts +3 -0
  59. package/src/tool/eval-tool.ts +17 -4
  60. package/src/tool/load-cell.ts +18 -3
  61. package/src/tool/magic-cell-host.ts +60 -2
  62. package/src/tool/magic-cells.ts +12 -2
  63. package/src/tool/run-eval-cell.ts +30 -3
  64. package/src/tool/runtime-label.ts +2 -1
  65. package/src/tool/types.ts +23 -1
package/CHANGELOG.md CHANGED
@@ -12,6 +12,60 @@
12
12
 
13
13
  ### Removed
14
14
 
15
+ ## [2026.10.10-3] - 2026-10-06
16
+
17
+ ### Breaking Changes
18
+
19
+ ### Added
20
+
21
+ - An opt-in process-isolated JavaScript kernel (`isolation.js: "process"` or `SENPI_CODEMODE_JS_ISOLATION=process`) runs each JavaScript kernel in its own subprocess instead of a worker thread, so a kernel crash (`SIGSEGV`, out-of-memory, `process.exit`, an uncaught error) can no longer take down the host session; the next cell runs on a replacement child with a restart notice naming the crash. It isolates crashes, not hostile code: a cell is trusted as in worker mode, and hostile code belongs in `isolate: true` sandbox cells ([#2752](https://github.com/code-yeongyu/senpi/issues/2752) tracks hostile-cell isolation). The child runs on the host's own runtime, exits as soon as its host is gone (including `SIGKILL`), carries large output and `BigInt`/`undefined` values as worker mode does, and its frames carry a per-process token so stray output is never taken for a frame. The default stays `"worker"` and worker-mode behaviour is unchanged ([#2452](https://github.com/code-yeongyu/senpi/issues/2452)).
22
+
23
+ ### Changed
24
+
25
+ - The README now documents every eval surface (helpers, magic cells, settings, JavaScript isolation modes and sandbox cells), and CI checks it against the helper census, so a new helper cannot ship undocumented ([#2787](https://github.com/code-yeongyu/senpi/pull/2787)).
26
+
27
+ ### Fixed
28
+
29
+ ### Removed
30
+
31
+ ## [2026.10.10-2] - 2026-10-05
32
+
33
+ ### Breaking Changes
34
+
35
+ ### Added
36
+
37
+ - `@code-yeongyu/senpi-codemode/executable-settings.json` lists the settings that name an executable run at session start (today `languages.pyInterpreter`). senpi's project-trust check reads it, so a project codemode file that sets one asks for trust, and a test fails if a new free-form string setting is neither on the list nor marked as not naming an executable ([#2772](https://github.com/code-yeongyu/senpi/pull/2772)).
38
+
39
+ ### Changed
40
+
41
+ ### Fixed
42
+
43
+ - Settings that were accepted but did nothing now take effect ([#2763](https://github.com/code-yeongyu/senpi/issues/2763)): `kernelTools.enabled: false` makes JavaScript `tool(fn)` and Python `@tool` refuse with `tools_unavailable`; `languages.pyInterpreter` makes the Python kernel run exactly that executable (a path that does not answer makes Python unavailable, with a warning naming the setting; one named by a project's own settings file is honored only in a trusted project); `prompt.advertiseHelpers: true` adds one line pointing at `tool_schema('eval:helpers')` to the eval description. Settings-file warnings (an unknown key, a fallback to defaults) now reach the user as a notice, or on stderr without a UI.
44
+
45
+ ### Removed
46
+
47
+ ## [2026.10.10] - 2026-10-05
48
+
49
+ ### Breaking Changes
50
+
51
+ ### Added
52
+
53
+ - Isolated eval cells: with `sandbox.enabled`, `isolate: true` runs a JavaScript cell in a fresh QuickJS VM with no persistence and no ambient host (only `tools.*`, `print`, `display`), streaming output under a credit window. Off by default; the eval schema is unchanged until the setting is on.
54
+ - A JavaScript cell that is only `%bun add <package ...>` or `%npm add <package ...>` installs packages into a per-session managed environment without restarting the kernel; the next cell imports them by bare name, the project's `package.json` and `node_modules` are untouched, lifecycle scripts never run, and a failed or cancelled install leaves the previous packages active ([#2452](https://github.com/code-yeongyu/senpi/issues/2452)).
55
+
56
+ ### Changed
57
+
58
+ ### Fixed
59
+
60
+ - An isolated (`isolate: true`) cell whose QuickJS runtime is missing now fails with `eval_isolate_unavailable` before any of its code runs, instead of a module-resolution error that named a host path ([#2452](https://github.com/code-yeongyu/senpi/issues/2452)).
61
+
62
+ - `%bun add` over a package first installed from a local directory now works when the new archive's top directory is not `package/` (a GitHub-style `<repo>-<sha>.tgz`, or a plain `.tar`): its name is read from the archive's own top-level directory, so the old directory's links are removed before bun installs ([#2452](https://github.com/code-yeongyu/senpi/issues/2452)).
63
+
64
+ - A nested tool call that is denied inside an `eval` cell (a permission denial or another hook's block) now reaches the cell as the plain denial, without the `Expected parameters:` schema hint that made it read like an argument error; argument failures still get the hint ([#2700](https://github.com/code-yeongyu/senpi/issues/2700)). Thanks to @MoerAI ([#2755](https://github.com/code-yeongyu/senpi/pull/2755)).
65
+ - An `eval` run with an invalid `language` value (for example `"python"`, `""` or `null`) now gets its own error listing the enabled languages, instead of the "run requires language" message meant for an omitted one; `peek` and `stop` still need no language ([#1395](https://github.com/code-yeongyu/senpi/issues/1395)). Thanks to @MoerAI.
66
+
67
+ ### Removed
68
+
15
69
  ## [2026.10.9] - 2026-10-04
16
70
 
17
71
  ### Breaking Changes
package/README.md CHANGED
@@ -59,6 +59,75 @@ task-tool names are known.
59
59
  A missing optional interpreter removes that language from the session's `eval`
60
60
  schema; it is not an installation failure.
61
61
 
62
+ ### JavaScript process isolation
63
+
64
+ By default the JavaScript kernel runs as a worker thread on the host's own
65
+ runtime (`isolation.js: "worker"`). Setting `isolation.js: "process"` (or
66
+ `SENPI_CODEMODE_JS_ISOLATION=process`, which wins over the file) runs each
67
+ JavaScript kernel in its own subprocess instead, so a kernel crash (a native
68
+ `SIGSEGV`, an out-of-memory, `process.exit`, an uncaught error) cannot take down
69
+ the host session. The next cell runs on a replacement child, and its result
70
+ carries the restart notice and names the crash.
71
+
72
+ **Trust model.** Process mode isolates crashes, not hostile code. A cell runs in
73
+ the same process that talks to the host, so it can reach everything that process
74
+ holds, exactly as a worker-mode cell can. The frame token described below guards
75
+ only against accidental corruption of the channel. Hostile code belongs in
76
+ `isolate: true` sandbox cells. Hostile-cell isolation for process mode is tracked
77
+ in [#2752](https://github.com/code-yeongyu/senpi/issues/2752).
78
+
79
+ **Core dumps (Linux).** The kernel child marks itself non-dumpable at start
80
+ (`prctl(PR_SET_DUMPABLE, 0)`). A Linux system that pipes core dumps to
81
+ `systemd-coredump` or apport otherwise holds a crashed child until its dump is
82
+ read, which froze the cell for about 30 s; without the dump the crash is reported
83
+ at once, still naming its signal. Two side effects: no core file is written for a
84
+ crash of the kernel child itself, and a same-user debugger cannot attach to it
85
+ (`ptrace` needs a dumpable process). A cell's own subprocesses are unaffected,
86
+ because `exec` resets the flag. Set `SENPI_KERNEL_CORE_DUMPS=1` to keep dumps and
87
+ debugger access while debugging the kernel.
88
+
89
+ How it works:
90
+
91
+ - **Runtime.** The child runs on the host's own runtime: bun when senpi runs on
92
+ Bun, node otherwise. A compiled binary whose executable is neither falls back to
93
+ `bun` or `node` on `PATH`, preferring the host's kind. The badge names the
94
+ runtime that actually runs the child, for example `js (bun 1.4.x, process)`.
95
+ - **Frames.** The host writes a random token as the child's first line on fd 0,
96
+ and every frame the child sends carries it. The host parses only lines that
97
+ carry the token; any other line (a cell's raw fd write, a child process's
98
+ output) is delivered as text. Frames are written in full. Large text is split
99
+ below the 10 MiB frame limit, and a result too large to return fails its cell,
100
+ not the kernel. A `BigInt` or an `undefined` field reaches the host as it does
101
+ in worker mode.
102
+ - **File descriptors.** Under Bun, the control channel moves to a private
103
+ duplicate of fd 0, fd 0 becomes `/dev/null`, and fd 1 is re-pointed at a
104
+ pipe that a reader thread turns into `text` frames as it is written. The pipe is kept
105
+ blocking (a child that switches it to non-blocking is undone before every cell and around
106
+ every child process a cell starts),
107
+ so output is never held in memory while a cell is busy. A cell's direct
108
+ `process.stdout.write` or fd 1 write (or a child process inheriting fd 1)
109
+ arrives before that cell's result. In a bun child, raw fd 1 bytes are not
110
+ interleaved with `console` output in the order they were written. A node
111
+ child cannot move its fds: cell output shares the channel, and fd 0 is the
112
+ channel. If libc cannot be loaded through `bun:ffi`, a bun child runs
113
+ the same way and still starts; the first cell's stderr says why.
114
+ - **Lifetime.** The child exits as soon as its control channel closes. That
115
+ covers every way the host ends, including `SIGKILL`. A watchdog thread also
116
+ compares the parent pid with the one recorded at start, so a child whose cell
117
+ never yields (a busy loop, a blocking call) still ends with its host, including
118
+ under a Linux subreaper. A process-mode kernel never outlives its host.
119
+ - **Failed start.** There is no inline fallback in process mode. A failed start
120
+ settles the waiting cell with a capability-gap result naming the missing
121
+ runtime (`Install bun or node, or use isolation.js: "worker"`). The result's
122
+ memory reading is the child's process footprint.
123
+
124
+ The default stays `"worker"`, and worker-mode behaviour is unchanged.
125
+
126
+ To run the `environments` suites with bun children (the runtime the product
127
+ uses), run them under bun: `SENPI_CODEMODE_JS_ISOLATION=process bunx --bun vitest
128
+ run test/environments`. Under plain `vitest` the children follow the test
129
+ runner's runtime, which is node.
130
+
62
131
  Python startup waits for the interpreter's `ready` event. It reports progress
63
132
  through `stdlib-imports`, `runtime-init`, and `host-init`; advancing to the next
64
133
  stage resets an inactivity guard rather than consuming a total startup budget.
@@ -67,6 +136,12 @@ Windows fresh-cache bootstrap p99 (30 samples). A stalled or failed start names
67
136
  the last stage and retains the interpreter's diagnostic error. The low-level
68
137
  `PythonKernel.start({ startupTimeoutMs })` override applies per stage.
69
138
 
139
+ Every `eval` run must explicitly select an enabled `language` (`js`, `py`, `rb`,
140
+ or `jl`); there is no default kernel, even when only one language is enabled.
141
+ Omitting `action` means `run`, so it also requires `language`. Control requests
142
+ with `action: "peek"` or `action: "stop"` use `cell_id` and do not require a
143
+ language.
144
+
70
145
  ### Session environment
71
146
 
72
147
  Every kernel starts with the active session's `PI_*` environment — `PI_SESSION_ID`,
@@ -117,6 +192,35 @@ cannot be read fails the cell with a message naming the path as written
117
192
  (`file not found: <path>`, `permission denied: <path>`); files over 8 MiB, and
118
193
  anything that is not a regular file, are refused.
119
194
 
195
+ ### JavaScript packages
196
+
197
+ A JavaScript cell whose only line is `%bun add <package ...>` or `%npm add <package ...>`
198
+ installs packages without restarting the kernel; the next cell imports them by bare name.
199
+ `%bun add` uses bun and `%npm add` uses npm; `environments.js.installer` (`bun`, `npm` or
200
+ `auto`: bun when it is on `PATH`, else npm) applies only where the magic names none, and the
201
+ kernel's own runtime never changes to get one.
202
+
203
+ In the default managed mode packages go into an environment revision under the session's
204
+ artifacts directory, never into the project's `package.json` or `node_modules`. With
205
+ `environments.managedRoot` set, every session using that root shares one revision chain: their
206
+ installs are serialized and each revision carries the packages installed before it.
207
+ A bare import resolves from the session directory's `node_modules` first and then from the
208
+ active revision, using each package's `exports` for `import` (ESM-only packages load), so the
209
+ project's own packages still win, and both win over Senpi's own dependencies (a package neither has may still load from Senpi's install). When a
210
+ newly added package is also in the project's `node_modules`, the result says
211
+ `environment_resolution_conflict` because the project copy resolves first.
212
+
213
+ Each install builds a new revision that carries the previous one forward (its `package.json`,
214
+ lockfile, and from `.npmrc` only `registry`, `@scope:registry`, `strict-ssl`, `ca` and `cafile`, rewritten as a regular file; credentials and every other key are dropped, and `bunfig.toml` is not carried)
215
+ and is published only when the installer succeeds; a failed or cancelled install, or one still
216
+ running when the session closes, leaves the previous revision active. Lifecycle scripts never run
217
+ (`--ignore-scripts`), installer flags are refused because the host picks the destination, and a
218
+ path-like package (`./pkg`, `file:../pkg`) is a path from the session directory. Package names
219
+ are split on whitespace; quoting is not supported. Error and installer text names the session directory as
220
+ `<cwd>`, the revision as `<root>`, the temp directory as `<tmp>`, the home directory as `~`, and an
221
+ absolute package path by its file name only. `%environment project` installs
222
+ into the session directory itself instead.
223
+
120
224
  ### Python packages
121
225
 
122
226
  A Python cell whose only line is `%pip install <requirements>` installs packages
@@ -145,6 +249,19 @@ that project, with concurrent installs serialised by a lock. Setting
145
249
  of `environment_install_failed`, `environment_install_cancelled`,
146
250
  `environment_installer_unavailable` or `environment_resolution_conflict`.
147
251
 
252
+ ### Isolated cells
253
+
254
+ With `sandbox.enabled: true`, a JavaScript call may pass `isolate: true`. The cell then runs in a fresh QuickJS VM (the vendored pi codemode runtime) instead of the persistent kernel:
255
+
256
+ - Nothing persists: globals from the kernel are not visible, and a global set in one isolated cell is gone in the next. `store()` throws `eval_isolate_no_state` and `load()` returns `undefined`.
257
+ - No ambient host: there is no `process`, `require`, file system, network, child processes or timers. The cell's only reach is `tools.<name>` (also `tool.<name>`; every active tool except `eval`, run through the same permission hooks as a direct call) plus `print`, `display`, `text` and `image`.
258
+ - `isolate: true` isolates the JavaScript, not the tools it calls: a tool keeps its normal reach. `tools.bash`, for example, runs a shell with the session's environment and files, under the same permission hooks as any other call.
259
+ - Output streams from the VM in frames under a 256 KiB credit window, so the VM never holds a large item; the host joins an item's frames before adding it to the result, and memory stays bounded by `sandbox.memoryMb`.
260
+ - It is a host entry in the JavaScript queue: it waits for earlier cells, Stop and the run budget end it, and the persistent kernel is never restarted for it.
261
+ - Limits: `sandbox.memoryMb` (default 64) and `sandbox.timeoutSeconds` (default 300). Failures are named: `eval_isolate_memory_limit`, `eval_isolate_timeout`, `eval_isolate_unresolved_promise` (a top-level promise that can never settle) and `eval_isolate_unavailable` (the runtime could not start).
262
+
263
+ While `sandbox.enabled` is `false` (the default) the `isolate` field is absent from the eval schema, and passing it is refused with `eval_isolate_invalid`, as are `isolate` with another language and `isolate` with `reset`.
264
+
148
265
  ## Settings
149
266
 
150
267
  Configuration is loaded in this order:
@@ -206,7 +323,7 @@ Configuration is loaded in this order:
206
323
  | `memory.retainedResultsMb` | `32` | In-memory byte budget (MiB) for the settled cells kept for `peek`/`list`, on top of the 32-cell count cap; the oldest go first and the newest is always kept. `0` keeps only the count cap. Env override: `SENPI_CODEMODE_RETAINED_RESULTS_MB` (a non-negative integer). |
207
324
  | `memory.retainedImagesMb` | `256` | Disk budget (MiB) for settled-cell images. Images of settled cells (foreground and detached) are written as base64 files under `<session artifacts>/settled-images/` instead of staying in memory, and `peek` reads them back, so it returns the full result. Beyond the budget the oldest files are deleted first; an evicted cell's files are deleted with it; the directory is removed when the session ends. A `peek` whose image file is gone returns the text plus a one-line note. `0` keeps only the count cap. Env override: `SENPI_CODEMODE_RETAINED_IMAGES_MB` (a non-negative integer). |
208
325
  | `memory.idleParkMinutes` | `0` | Off by default. When greater than 0, a kernel with no cell running or queued for this many minutes is closed to give its memory back, and the next cell for that language starts a fresh one; that cell's result says the kernel was restarted and every earlier global is lost. Applies to every language. |
209
- | `languages.pyInterpreter` | unset | Explicit Python interpreter path. Unset keeps today's `PATH` detection. |
326
+ | `languages.pyInterpreter` | unset | Explicit Python interpreter path: the Python kernel runs exactly that executable (a path with spaces is fine). A path that does not answer `--version` makes Python unavailable for the session, with a warning naming the setting. Unset keeps `PATH` detection. |
210
327
  | `environments.managedRoot` | unset | Root directory for managed per-session environments; unset uses the session's default location. |
211
328
  | `environments.autoProvision` | `true` | Lets installs provision a managed environment. `false` makes installs refuse with `environment_installer_unavailable`. |
212
329
  | `environments.js.installer` | `auto` | Installer for the managed JavaScript environment: `auto`, `bun` or `npm`. |
@@ -216,16 +333,16 @@ Configuration is loaded in this order:
216
333
  | `sandbox.memoryMb` | `64` | Memory cap (MiB) for a sandbox cell. Env override: `SENPI_CODEMODE_SANDBOX_MEMORY_MB` (a positive integer wins). |
217
334
  | `sandbox.timeoutSeconds` | `300` | Time limit for a sandbox cell. |
218
335
  | `prompt.advertiseHelpers` | `false` | When `true`, one pointer line to `tool_schema('eval:helpers')` is appended to the eval description. |
219
- | `kernelTools.enabled` | `true` | Allows cells to define kernel tools (`tool(fn)`, `@tool`). `false` makes them refuse with `tools_unavailable`. |
336
+ | `kernelTools.enabled` | `true` | Allows cells to define kernel tools. `false` makes JavaScript `tool(fn)` and Python `@tool` refuse with `tools_unavailable`, naming this setting. |
220
337
 
221
- The `languages.pyInterpreter`, `environments.*`, `isolation.*`, `sandbox.*`, `prompt.*` and `kernelTools.*` keys are accepted and validated now, with the defaults shown, which match today's behaviour. The effect each of those rows describes takes effect when its feature ships; until then, setting a key changes nothing.
338
+ Every key in the table takes the effect its row describes.
222
339
 
223
340
  `SENPI_CODEMODE_PY`, `SENPI_CODEMODE_JS`, `SENPI_CODEMODE_RB`, and
224
341
  `SENPI_CODEMODE_JL` override the corresponding file setting. `1` or `true`
225
342
  enables; `0` or `false` disables. Any other value leaves the file setting in
226
343
  effect.
227
344
 
228
- A top-level key this version does not know is ignored with one warning naming it, and the rest of the file still applies, so a settings file written for a newer senpi keeps working. Malformed JSON, or an invalid value or an unknown key inside a known setting, falls back to all defaults with a warning.
345
+ Settings problems reach the user as a notice in the interactive UI, or a `[senpi-codemode]` line on stderr without one. A top-level key this version does not know is ignored with one warning naming it, and the rest of the file still applies, so a settings file written for a newer senpi keeps working. Malformed JSON, or an invalid value or an unknown key inside a known setting, falls back to all defaults with a warning.
229
346
  The detached-cell environment override uses the run-budget parser: a positive
230
347
  base-10 integer wins over the file value; zero, negative, and malformed values
231
348
  leave the file value in effect.
@@ -240,6 +357,8 @@ options object and asynchronous helpers are `await`-able.
240
357
  | --- | --- |
241
358
  | `display(value)` | Emits text, structured JSON, markdown, or image display data. Images reach the model only through `display`: pass a figure, raw image bytes (PNG/JPEG/GIF/WebP/BMP sniffed), a `data:` URL, a `Blob`-like or `Bun.Image` value, a marshalled tool result, or one of its `images[i]` frames. |
242
359
  | `print(value, ...)` | Emits text output. |
360
+ | `text(value)` (rb, jl) | Emits `value` as one text item without separators (`print` joins its arguments and calls it). |
361
+ | `display_image(base64, mime_type)` (rb, jl) | Emits an image from base64 data; `mime_type` defaults to `image/png` (Ruby: `mime_type:` keyword). |
243
362
  | `read(path, offset?, limit?)` | Reads text with 1-indexed line slicing. `local://` paths resolve under the session artifact root. |
244
363
  | `write(path, content)` | Creates parent directories and writes text. `local://` paths persist in the session artifact root. |
245
364
  | `env(key?, value?)` | Reads all kernel environment values, one value, or sets one value. Includes the session's `PI_*` values (see [Session environment](#session-environment)). |
@@ -251,7 +370,7 @@ options object and asynchronous helpers are `await`-able.
251
370
  | `completion(prompt, model?, system?, schema?)` | Requests a one-shot host completion; `schema` asks the host to parse structured output. |
252
371
  | `agent(prompt, ...)` | Delegates to the configured active `taskTools.task` tool. Supports background handles and structured JSON results. |
253
372
  | `wait(handles, timeout?, mode?)` | Blocks the cell until the given handles settle (agent handle records, `handle()` views, completion handles, closed workpools, or saved `{kind, id, run_epoch}` references). `mode` is `all` (values in input order; the first failed, cancelled, or lost handle raises), `any` (`{index, ref, value}` of the first success), or `settled` (one outcome per input slot). `timeout` is wall-clock seconds from entry; on expiry `eval_wait_timeout` is raised and nothing is cancelled. Rides the bridge-call path, so the run budget pauses while parked. Agent and workpool handles need the host's `EvalHandleHost` capability (`eval_wait_unavailable` without it); completion handles always work. Julia extends `Base.wait` for handle views (`wait(handle(node))`). Details: `tool_schema("eval:wait")`. |
254
- | `handle(node \| ref \| {pool_id})` | Returns a rich view: the legacy record's fields plus a non-enumerable `control` (Python: attribute on a `dict` subclass; Ruby: singleton method) with `status()`, `output(format?, offset?, limit?)`, `send(message)` (agent handles only), `cancel()` (idempotent for that run epoch; never touches a successor run), and `wait(timeout?)`. Every control call is fenced by owner, id, and `run_epoch` inside the task owner (`eval_handle_stale`, `eval_handle_forbidden`). `completion(prompt, handle: true)` returns such a view for a host completion. The `agent(..., handle: true)` record itself is unchanged. Details: `tool_schema("eval:helpers")`. |
373
+ | `handle(node \| ref \| {pool_id})` | Returns a rich view: the legacy record's fields plus a non-enumerable `control` (Python: attribute on a `dict` subclass; Ruby: singleton method) with `.status()`, `.output(format?, offset?, limit?)`, `.send(message)` (agent handles only), `.cancel()` (idempotent for that run epoch; never touches a successor run), and `.wait(timeout?)`. Every control call is fenced by owner, id, and `run_epoch` inside the task owner (`eval_handle_stale`, `eval_handle_forbidden`). `completion(prompt, handle: true)` returns such a view for a host completion. The `agent(..., handle: true)` record itself is unchanged. Details: `tool_schema("eval:helpers")`. |
255
374
  | `workpool(agent, name, mode?, tools?)` | Creates a thin adapter over the normal host `workpool` tool; exposes `pool_id`, `push(items)`, `close()`, `inspect()`, and `cancel()`. JS awaits creation and operations. `tools` is a list of kernel-tool names this cell defined; the pool's workers may call exactly those, the host refuses any name the caller doesn't hold, and a value that isn't a list of names raises `invalid_tools`. Ruby and Julia define no kernel tools, so they have nothing to grant. |
256
375
  | `output(ids, format?, offset?, limit?)` | Delegates transcript retrieval to the configured active `taskTools.output` tool. |
257
376
  | `parallel(thunks)` | Runs thunks through the configured bounded pool while preserving input order. |
@@ -443,6 +562,13 @@ original sizes, then a plain-path notice such as
443
562
  package.
444
563
  - Task transcript formats are limited to full (`raw`) and trailing (`tail`)
445
564
  output. Query, JSON, and stripped metadata formats are task-engine concerns.
565
+ - There is no `pool.wait()`: a workpool's aggregate is delivered by the host after
566
+ `close()`, so a kernel never blocks on another engine's workers.
567
+ - There is no `code_mode_only` mode and no speculative cell execution; both are
568
+ deferred to a separate design (omo#9232) rather than approximated here.
569
+ - Kernels are never shared across owners: each session (and each in-process child
570
+ with its own eval tool) owns its kernels, and handles are fenced by owner, id and
571
+ run epoch.
446
572
 
447
573
  ## Security and lifecycle
448
574
 
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-codemode",
3
- "version": "2026.10.9",
3
+ "version": "2026.10.10-3",
4
4
  "description": "Source-only senpi extension package for codemode evaluation tools",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
7
7
  "types": "./src/index.ts",
8
8
  "exports": {
9
9
  ".": "./src/index.ts",
10
- "./package.json": "./package.json"
10
+ "./package.json": "./package.json",
11
+ "./executable-settings.json": "./src/config/executable-settings.json"
11
12
  },
12
13
  "files": [
13
14
  "src",
@@ -32,15 +33,15 @@
32
33
  },
33
34
  "dependencies": {
34
35
  "@babel/parser": "8.0.4",
35
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.9",
36
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.10-3",
36
37
  "quickjs-wasi": "3.6.2",
37
38
  "typebox": "1.3.34"
38
39
  },
39
40
  "peerDependencies": {
40
- "@code-yeongyu/senpi": "2026.10.9"
41
+ "@code-yeongyu/senpi": "2026.10.10-3"
41
42
  },
42
43
  "devDependencies": {
43
- "@code-yeongyu/senpi": "2026.10.9"
44
+ "@code-yeongyu/senpi": "2026.10.10-3"
44
45
  },
45
46
  "keywords": [
46
47
  "senpi",
@@ -237,10 +237,15 @@ export function parseBridgeJsonLine(line: string, options: BridgeFrameOptions =
237
237
  export function decodeBridgeFrame(line: string, options: BridgeFrameOptions = {}): BridgeMessageDecodeResult {
238
238
  const parsed = parseBridgeJsonLine(line, options);
239
239
  if (!parsed.ok) return parsed;
240
- if (Value.Check(bridgeMessageSchema, parsed.value)) {
241
- return { ok: true, message: parsed.value };
240
+ return validateBridgeMessage(parsed.value);
241
+ }
242
+
243
+ /** Checks an already-parsed frame against the bridge schema (for transports that transform values before checking). */
244
+ export function validateBridgeMessage(value: unknown): BridgeMessageDecodeResult {
245
+ if (Value.Check(bridgeMessageSchema, value)) {
246
+ return { ok: true, message: value };
242
247
  }
243
- const firstError = Value.Errors(bridgeMessageSchema, parsed.value)[0];
248
+ const firstError = Value.Errors(bridgeMessageSchema, value)[0];
244
249
  const message = firstError ? `Invalid bridge message: ${firstError.message}` : "Invalid bridge message";
245
250
  return { ok: false, error: { code: "invalid_message", message } };
246
251
  }
@@ -0,0 +1,5 @@
1
+ {
2
+ "projectExecutableSettings": [
3
+ "languages.pyInterpreter"
4
+ ]
5
+ }
@@ -0,0 +1,51 @@
1
+ import { join } from "node:path";
2
+ import executableSettings from "./executable-settings.json" with { type: "json" };
3
+ import type { CodemodeSettings } from "./settings.ts";
4
+
5
+ /**
6
+ * Settings that name an executable run at session start (an interpreter's `--version` probe, then the kernel). The
7
+ * one list of them lives in `executable-settings.json`, which senpi's project-trust check also reads: a project
8
+ * codemode file that sets any of them asks for trust. Without trust they are dropped with a warning and never run.
9
+ * Trust is asked only when the project's own file sets one, so a host that cannot answer still starts sessions;
10
+ * such a host counts as not trusting the project.
11
+ */
12
+ export const PROJECT_EXECUTABLE_SETTINGS: readonly string[] = executableSettings.projectExecutableSettings;
13
+
14
+ export function withoutUntrustedInterpreter<Settings extends CodemodeSettings>(
15
+ settings: Settings,
16
+ source: string | null,
17
+ cwd: string,
18
+ isProjectTrusted: (() => boolean) | undefined,
19
+ ): { readonly settings: Settings; readonly warning?: string } {
20
+ const projectFile = join(cwd, ".senpi", "codemode.json");
21
+ if (source !== projectFile) return { settings };
22
+ const named = PROJECT_EXECUTABLE_SETTINGS.filter((path) => valueAt(settings, path) !== undefined);
23
+ if (named.length === 0 || isProjectTrusted?.() === true) return { settings };
24
+ let trimmed: Settings = settings;
25
+ for (const path of named) trimmed = withoutPath(trimmed, path);
26
+ const list = named.map((path) => `${path} "${String(valueAt(settings, path))}"`).join(", ");
27
+ return {
28
+ settings: trimmed,
29
+ warning: `${list} in ${projectFile} is ignored because this project is not trusted; it was not run`,
30
+ };
31
+ }
32
+
33
+ function valueAt(value: unknown, path: string): unknown {
34
+ let current: unknown = value;
35
+ for (const key of path.split(".")) {
36
+ if (typeof current !== "object" || current === null) return undefined;
37
+ current = Reflect.get(current, key);
38
+ }
39
+ return current;
40
+ }
41
+
42
+ function withoutPath<T>(value: T, path: string): T {
43
+ const copy = structuredClone(value);
44
+ const keys = path.split(".");
45
+ const last = keys.pop();
46
+ let parent: unknown = copy;
47
+ for (const key of keys)
48
+ parent = typeof parent === "object" && parent !== null ? Reflect.get(parent, key) : undefined;
49
+ if (last !== undefined && typeof parent === "object" && parent !== null) Reflect.deleteProperty(parent, last);
50
+ return copy;
51
+ }
@@ -20,6 +20,8 @@ export function resolveEnabledLanguages(
20
20
  env: Environment = process.env,
21
21
  ): CodemodeSettings["languages"] {
22
22
  return {
23
+ // Keep the non-flag keys (languages.pyInterpreter); only the four enable flags take env overrides.
24
+ ...settings.languages,
23
25
  py: resolveLanguage(settings.languages.py, env[languageEnvironmentFlags.py]),
24
26
  js: resolveLanguage(settings.languages.js, env[languageEnvironmentFlags.js]),
25
27
  rb: resolveLanguage(settings.languages.rb, env[languageEnvironmentFlags.rb]),
@@ -0,0 +1,208 @@
1
+ import { mkdir, readFile, realpath, rm, stat } from "node:fs/promises";
2
+ import { isAbsolute, join } from "node:path";
3
+ import type { ResolvedCodemodeSettings } from "../config/settings.ts";
4
+ import { withRootLock } from "./install-lock.ts";
5
+ import {
6
+ absoluteSpec,
7
+ type JsInstallerChoice,
8
+ parseJsPackages,
9
+ requestedPackageName,
10
+ resolveJsInstaller,
11
+ runJsInstall,
12
+ withoutHostPaths,
13
+ } from "./js-installer.ts";
14
+ import { carryNpmrcSettings, recordedFileSpecs, seedPackageJson } from "./js-revision-files.ts";
15
+ import { assertBuiltRevisionLinks, assertNoLinksBelow, recordedInstallSources } from "./no-links.ts";
16
+ import { projectResolves } from "./project-resolves.ts";
17
+ import type { EnvironmentMode } from "./py-environment.ts";
18
+ import { EnvironmentError } from "./py-installer.ts";
19
+ import { mkdirPrivate, publishNextRevision, readActiveRevision } from "./revision-store.ts";
20
+
21
+ /** The paths in a revision that the installer writes. */
22
+ const INSTALLER_WRITES = [
23
+ "package.json",
24
+ "package-lock.json",
25
+ "bun.lock",
26
+ "bun.lockb",
27
+ "node_modules",
28
+ ".npmrc",
29
+ "bunfig.toml",
30
+ ];
31
+
32
+ export interface JsInstallReceipt {
33
+ readonly installer: "bun" | "npm";
34
+ readonly mode: EnvironmentMode;
35
+ readonly revision: number | undefined;
36
+ readonly added: readonly string[];
37
+ readonly shadowed: readonly string[];
38
+ }
39
+
40
+ export interface JsEnvironmentsOptions {
41
+ readonly artifactsDir: string;
42
+ readonly cwd: string;
43
+ readonly runtime: string;
44
+ readonly env: NodeJS.ProcessEnv;
45
+ readonly settings: Pick<ResolvedCodemodeSettings, "environments">;
46
+ }
47
+
48
+ export class JsEnvironments {
49
+ readonly #options: JsEnvironmentsOptions;
50
+ #mode: EnvironmentMode = "managed";
51
+ #packageRoot: string | undefined;
52
+
53
+ constructor(options: JsEnvironmentsOptions) {
54
+ this.#options = options;
55
+ }
56
+
57
+ get mode(): EnvironmentMode {
58
+ return this.#mode;
59
+ }
60
+
61
+ get packageRoot(): string | undefined {
62
+ return this.#mode === "managed" ? this.#packageRoot : undefined;
63
+ }
64
+
65
+ async setMode(mode: EnvironmentMode): Promise<string> {
66
+ this.#mode = mode;
67
+ if (mode === "project") return this.#options.cwd;
68
+ this.#packageRoot = (await readActiveRevision(this.#managedBase()))?.dir;
69
+ return this.#managedBase();
70
+ }
71
+
72
+ async install(
73
+ requested: string,
74
+ signal: AbortSignal,
75
+ onOutput?: (stream: "stdout" | "stderr", data: string) => void,
76
+ requestedInstaller?: "bun" | "npm",
77
+ ): Promise<JsInstallReceipt> {
78
+ const environments = this.#options.settings.environments;
79
+ if (environments?.autoProvision === false) {
80
+ throw new EnvironmentError(
81
+ "environment_installer_unavailable",
82
+ "installs are turned off for this project (environments.autoProvision is false)",
83
+ );
84
+ }
85
+ const packages = parseJsPackages(requested).map((spec) => absoluteSpec(spec, this.#options.cwd));
86
+ // `%bun add` and `%npm add` name their installer; the setting applies only where the magic does not.
87
+ const choice: JsInstallerChoice = requestedInstaller ?? environments?.js?.installer ?? "auto";
88
+ const { installer, command } = resolveJsInstaller(choice, this.#options.env);
89
+ let recordedSpecs: readonly string[] = [];
90
+ const run = (root: string, remove?: string) =>
91
+ runJsInstall({
92
+ installer,
93
+ command,
94
+ root,
95
+ packages,
96
+ ...(remove === undefined ? {} : { remove }),
97
+ recordedSpecs,
98
+ cwd: this.#options.cwd,
99
+ env: this.#options.env,
100
+ signal,
101
+ ...(onOutput === undefined ? {} : { onOutput }),
102
+ });
103
+ // The lock and the revision store fail with raw file system errors; they reach the cell redacted.
104
+ const redacted =
105
+ (...roots: string[]) =>
106
+ (error: unknown) => {
107
+ const raw =
108
+ error instanceof EnvironmentError
109
+ ? error.message.slice(error.code.length + 2)
110
+ : String(error instanceof Error ? error.message : error);
111
+ const text = roots.reduce(
112
+ (message, root) => withoutHostPaths(message, { root, cwd: this.#options.cwd, packages, recordedSpecs }),
113
+ raw,
114
+ );
115
+ if (error instanceof EnvironmentError) throw new EnvironmentError(error.code, text);
116
+ // An abort while waiting for the lock or before the publish is a cancel, not a failure.
117
+ if (signal.aborted)
118
+ throw new EnvironmentError("environment_install_cancelled", `the install was cancelled: ${text}`);
119
+ throw new EnvironmentError("environment_install_failed", text);
120
+ };
121
+ if (this.#mode === "project") {
122
+ const before = await dependencyNames(this.#options.cwd);
123
+ const lockRoot = join(this.#options.cwd, ".senpi", "js-packages");
124
+ await mkdir(lockRoot, { recursive: true });
125
+ await withRootLock(lockRoot, async () => await run(this.#options.cwd), signal).catch(redacted(lockRoot));
126
+ const added = (await dependencyNames(this.#options.cwd)).filter((name) => !before.includes(name));
127
+ return { installer, mode: "project", revision: undefined, added, shadowed: [] };
128
+ }
129
+ let added: string[] = [];
130
+ const requestedBase = this.#managedBase();
131
+ const base = await canonicalBase(this.#managedRoot(), requestedBase).catch(redacted(requestedBase));
132
+ const { revision } = await publishNextRevision(
133
+ base,
134
+ async (staging) => {
135
+ await carryNpmrcSettings(staging);
136
+ await rm(join(staging, "bunfig.toml"), { recursive: true, force: true });
137
+ await seedPackageJson(staging);
138
+ // Every path the installer writes must be the revision's own, never a link out of it.
139
+ for (const entry of INSTALLER_WRITES) await assertNoLinksBelow(base, join(staging, entry));
140
+ const before = await dependencyNames(staging);
141
+ recordedSpecs = await recordedFileSpecs(staging);
142
+ // A package whose source changes kind (a directory to a tarball or registry version, or back) is removed
143
+ // first: bun keeps its per-file links into the old directory otherwise (#2758).
144
+ for (const name of await sourceKindChanges(staging, packages)) await run(staging, name);
145
+ await run(staging);
146
+ await assertBuiltRevisionLinks(staging);
147
+ added = (await dependencyNames(staging)).filter((name) => !before.includes(name));
148
+ },
149
+ signal,
150
+ ).catch(redacted(base, requestedBase));
151
+ this.#packageRoot = revision.dir;
152
+ const shadowed = added.filter((name) => projectResolves(this.#options.cwd, name));
153
+ return { installer, mode: "managed", revision: revision.number, added, shadowed };
154
+ }
155
+
156
+ #managedRoot(): string {
157
+ return this.#options.settings.environments?.managedRoot ?? this.#options.artifactsDir;
158
+ }
159
+
160
+ #managedBase(): string {
161
+ return join(this.#managedRoot(), "environments", "js", this.#options.runtime);
162
+ }
163
+ }
164
+
165
+ async function dependencyNames(root: string): Promise<string[]> {
166
+ try {
167
+ const manifest: unknown = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
168
+ if (typeof manifest !== "object" || manifest === null || !("dependencies" in manifest)) return [];
169
+ const dependencies = manifest.dependencies;
170
+ return typeof dependencies === "object" && dependencies !== null ? Object.keys(dependencies) : [];
171
+ } catch (error) {
172
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return [];
173
+ throw error;
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Creates the managed base, checks that nothing below the managed root is a link, and returns its realpath: npm keys
179
+ * a linked package in its lockfile by a path relative to the prefix, and a prefix under a linked directory (macOS
180
+ * `/var` is a link to `/private/var`) gives keys that stop resolving once the staged revision is renamed.
181
+ */
182
+ async function canonicalBase(managedRoot: string, base: string): Promise<string> {
183
+ await assertNoLinksBelow(managedRoot, base);
184
+ await mkdirPrivate(base);
185
+ await assertNoLinksBelow(managedRoot, base);
186
+ return await realpath(base);
187
+ }
188
+
189
+ /** Names among `packages` whose recorded source is a directory and is not the same directory now, or the reverse. */
190
+ export async function sourceKindChanges(revision: string, packages: readonly string[]): Promise<string[]> {
191
+ const installed = new Set(await dependencyNames(revision));
192
+ const sources = await recordedInstallSources(revision);
193
+ const changed: string[] = [];
194
+ for (const spec of packages) {
195
+ const name = await requestedPackageName(spec);
196
+ if (name === undefined || !installed.has(name)) continue;
197
+ const directory = await localDirectory(spec);
198
+ if (directory !== sources.get(name)) changed.push(name);
199
+ }
200
+ return changed;
201
+ }
202
+
203
+ async function localDirectory(spec: string): Promise<string | undefined> {
204
+ const path = spec.startsWith("file:") ? spec.slice("file:".length) : spec;
205
+ if (!isAbsolute(path)) return undefined;
206
+ const resolved = await realpath(path).catch(() => undefined);
207
+ return resolved !== undefined && (await stat(resolved)).isDirectory() ? resolved : undefined;
208
+ }