@code-yeongyu/senpi-codemode 2026.10.8 → 2026.10.10
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.
- package/CHANGELOG.md +42 -0
- package/README.md +100 -4
- package/package.json +4 -4
- package/src/bridge/http-server.ts +9 -1
- package/src/bridge/protocol.ts +4 -0
- package/src/bridges/eval-virtual-schemas.ts +1 -0
- package/src/environments/js-environments.ts +208 -0
- package/src/environments/js-installer.ts +268 -0
- package/src/environments/js-revision-files.ts +91 -0
- package/src/environments/no-links.ts +132 -0
- package/src/environments/project-resolves.ts +54 -0
- package/src/environments/py-installer.ts +64 -4
- package/src/environments/python-environments.ts +85 -0
- package/src/environments/revision-store.ts +38 -7
- package/src/extension/bridge-tool-call.ts +2 -0
- package/src/extension/cell-kernel-tools.ts +28 -0
- package/src/extension/kernel-replacement.ts +20 -0
- package/src/extension/runtime-factory.ts +21 -0
- package/src/extension/session-manager-proxy.ts +6 -0
- package/src/extension/session-manager.ts +10 -2
- package/src/index.ts +2 -0
- package/src/kernels/jl/prelude.jl +6 -1
- package/src/kernels/js/cell-dispatch.ts +44 -0
- package/src/kernels/js/context-manager.ts +25 -15
- package/src/kernels/js/host-entries.ts +62 -0
- package/src/kernels/js/local-module-loader.ts +21 -5
- package/src/kernels/js/worker-core.js +2 -0
- package/src/kernels/js/worker-package-resolve.js +153 -0
- package/src/kernels/js/worker-runtime.js +3 -1
- package/src/kernels/js/workpool.js +5 -2
- package/src/kernels/py/host-cell.ts +8 -31
- package/src/kernels/py/kernel.ts +8 -2
- package/src/kernels/py/prelude.py +112 -9
- package/src/kernels/py/transport.ts +13 -1
- package/src/kernels/rb/workpool.rb +7 -1
- package/src/kernels/sandbox/sandbox-cell.ts +116 -0
- package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +1 -0
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +14 -1
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +4 -1
- package/src/kernels/sandbox/vendor/pi-codemode/types.ts +2 -0
- package/src/kernels/shared/cell-source-at-start.ts +18 -0
- package/src/kernels/shared/host-cell.ts +57 -0
- package/src/tool/cell-handler.ts +10 -12
- package/src/tool/eval-request.ts +23 -1
- package/src/tool/eval-tool-options.ts +6 -0
- package/src/tool/eval-tool.ts +16 -4
- package/src/tool/load-cell.ts +88 -0
- package/src/tool/magic-cell-host.ts +123 -0
- package/src/tool/magic-cells.ts +75 -0
- package/src/tool/run-eval-cell.ts +57 -0
- package/src/tool/types.ts +48 -1
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,48 @@
|
|
|
12
12
|
|
|
13
13
|
### Removed
|
|
14
14
|
|
|
15
|
+
## [2026.10.10] - 2026-10-05
|
|
16
|
+
|
|
17
|
+
### Breaking Changes
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- 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.
|
|
22
|
+
- 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)).
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- 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)).
|
|
29
|
+
|
|
30
|
+
- `%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)).
|
|
31
|
+
|
|
32
|
+
- 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)).
|
|
33
|
+
- 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.
|
|
34
|
+
|
|
35
|
+
### Removed
|
|
36
|
+
|
|
37
|
+
## [2026.10.9] - 2026-10-04
|
|
38
|
+
|
|
39
|
+
### Breaking Changes
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- Python `agent(prompt, tools=[...])` grants the child the cell's `@tool` functions by name, like JavaScript's `agent(prompt, { tools })`; anything other than a list of names is refused with `invalid_tools` ([#2731](https://github.com/code-yeongyu/senpi/issues/2731)).
|
|
44
|
+
- `workpool(agent, name, {mode, tools})` forwards `tools`, a list of kernel-tool names the cell defined, to the host workpool unchanged in all four languages, so pool workers can call them; anything other than a list of names is refused with `invalid_tools` before reaching the host. Which kernels' tools a host accepts is the host's call: JavaScript `tool(fn)` tools work on omo today, while Python `@tool` tools need omo#9529 ([#2452](https://github.com/code-yeongyu/senpi/issues/2452)).
|
|
45
|
+
- A Python or JavaScript cell that is only `%load <path>` runs that local file as the cell: its definitions persist, Python tracebacks name the file and its sibling modules import, and JavaScript resolves the file's relative imports from its directory; remote URLs are refused ([#2452](https://github.com/code-yeongyu/senpi/issues/2452)).
|
|
46
|
+
- A Python cell that is only `%pip install <requirements>` installs packages without restarting the kernel; the next cell imports them. Packages go into the session's own environment (or `<cwd>/.senpi/python-packages` after `%environment project`), never the interpreter's site-packages or the user site, and a failed or cancelled install leaves the previous packages active ([#2452](https://github.com/code-yeongyu/senpi/issues/2452)).
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- A Python cell can now grant its `@tool` functions to a child ([#2731](https://github.com/code-yeongyu/senpi/issues/2731)). Host calls from Python cells (`tool.task(..., tools=[...])`, `agent(..., tools=[...])`, `workpool(..., tools=[...])`) reached the host with no kernel-tools capability, so every such grant was refused as unavailable. Each call now carries its cell, and the host gives it that cell's capability while the cell runs; a call from another, unknown or finished cell gets none.
|
|
53
|
+
- `%pip` parsing follow-ups ([#2689](https://github.com/code-yeongyu/senpi/pull/2689)): a `%pip` or `%environment` line after code now says to put it on its own cell instead of "Unsupported line magic"; a comment line ending in a backslash no longer swallows the `%pip` line after it; inside double quotes a backslash is kept unless it escapes a quote, backslash, `$` or a backtick, as a POSIX shell does.
|
|
54
|
+
|
|
55
|
+
### Removed
|
|
56
|
+
|
|
15
57
|
## [2026.10.8] - 2026-10-04
|
|
16
58
|
|
|
17
59
|
### Breaking Changes
|
package/README.md
CHANGED
|
@@ -67,6 +67,12 @@ Windows fresh-cache bootstrap p99 (30 samples). A stalled or failed start names
|
|
|
67
67
|
the last stage and retains the interpreter's diagnostic error. The low-level
|
|
68
68
|
`PythonKernel.start({ startupTimeoutMs })` override applies per stage.
|
|
69
69
|
|
|
70
|
+
Every `eval` run must explicitly select an enabled `language` (`js`, `py`, `rb`,
|
|
71
|
+
or `jl`); there is no default kernel, even when only one language is enabled.
|
|
72
|
+
Omitting `action` means `run`, so it also requires `language`. Control requests
|
|
73
|
+
with `action: "peek"` or `action: "stop"` use `cell_id` and do not require a
|
|
74
|
+
language.
|
|
75
|
+
|
|
70
76
|
### Session environment
|
|
71
77
|
|
|
72
78
|
Every kernel starts with the active session's `PI_*` environment — `PI_SESSION_ID`,
|
|
@@ -100,6 +106,93 @@ getter and may name a file that does not exist yet. It honors session-directory
|
|
|
100
106
|
and in-memory sessions; it cannot be derived reliably from `PI_SESSION_FILE`. If the host
|
|
101
107
|
omits the getter, the variable is unset rather than inherited from the launching process.
|
|
102
108
|
|
|
109
|
+
### Loading a file
|
|
110
|
+
|
|
111
|
+
A Python or JavaScript cell whose only line is `%load <path>` reads that file and
|
|
112
|
+
runs it as the cell, so its definitions persist like any cell. The path is relative
|
|
113
|
+
to the session directory, or a `local://` or `file://` URL; other URL schemes are
|
|
114
|
+
refused and nothing is fetched. The cell runs as its file: Python compiles it under
|
|
115
|
+
the file's name, so tracebacks name the file and line, and sets `__file__` and puts
|
|
116
|
+
the file's directory first on the import path, so `from sibling import g` resolves
|
|
117
|
+
next to it. JavaScript evaluates it under the file's name and resolves its relative
|
|
118
|
+
imports from the file's directory. The file is read when the cell's turn comes in
|
|
119
|
+
the kernel's queue, so a `%load` queued behind the cell that writes the file runs
|
|
120
|
+
what that cell wrote. When the cell ends, Python takes the file's directory back off
|
|
121
|
+
the import path and restores `__file__`, so later cells import as before. A file that
|
|
122
|
+
cannot be read fails the cell with a message naming the path as written
|
|
123
|
+
(`file not found: <path>`, `permission denied: <path>`); files over 8 MiB, and
|
|
124
|
+
anything that is not a regular file, are refused.
|
|
125
|
+
|
|
126
|
+
### JavaScript packages
|
|
127
|
+
|
|
128
|
+
A JavaScript cell whose only line is `%bun add <package ...>` or `%npm add <package ...>`
|
|
129
|
+
installs packages without restarting the kernel; the next cell imports them by bare name.
|
|
130
|
+
`%bun add` uses bun and `%npm add` uses npm; `environments.js.installer` (`bun`, `npm` or
|
|
131
|
+
`auto`: bun when it is on `PATH`, else npm) applies only where the magic names none, and the
|
|
132
|
+
kernel's own runtime never changes to get one.
|
|
133
|
+
|
|
134
|
+
In the default managed mode packages go into an environment revision under the session's
|
|
135
|
+
artifacts directory, never into the project's `package.json` or `node_modules`. With
|
|
136
|
+
`environments.managedRoot` set, every session using that root shares one revision chain: their
|
|
137
|
+
installs are serialized and each revision carries the packages installed before it.
|
|
138
|
+
A bare import resolves from the session directory's `node_modules` first and then from the
|
|
139
|
+
active revision, using each package's `exports` for `import` (ESM-only packages load), so the
|
|
140
|
+
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
|
|
141
|
+
newly added package is also in the project's `node_modules`, the result says
|
|
142
|
+
`environment_resolution_conflict` because the project copy resolves first.
|
|
143
|
+
|
|
144
|
+
Each install builds a new revision that carries the previous one forward (its `package.json`,
|
|
145
|
+
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)
|
|
146
|
+
and is published only when the installer succeeds; a failed or cancelled install, or one still
|
|
147
|
+
running when the session closes, leaves the previous revision active. Lifecycle scripts never run
|
|
148
|
+
(`--ignore-scripts`), installer flags are refused because the host picks the destination, and a
|
|
149
|
+
path-like package (`./pkg`, `file:../pkg`) is a path from the session directory. Package names
|
|
150
|
+
are split on whitespace; quoting is not supported. Error and installer text names the session directory as
|
|
151
|
+
`<cwd>`, the revision as `<root>`, the temp directory as `<tmp>`, the home directory as `~`, and an
|
|
152
|
+
absolute package path by its file name only. `%environment project` installs
|
|
153
|
+
into the session directory itself instead.
|
|
154
|
+
|
|
155
|
+
### Python packages
|
|
156
|
+
|
|
157
|
+
A Python cell whose only line is `%pip install <requirements>` installs packages
|
|
158
|
+
without restarting the kernel: variables, the process and its working directory
|
|
159
|
+
stay as they were. The cell runs in the kernel's queue like any other cell (it waits
|
|
160
|
+
for the cell ahead of it, can be cancelled while queued, and detaches past the
|
|
161
|
+
foreground window), but the install runs on the host, so its time does not count
|
|
162
|
+
against the run budget. The next cell imports the new packages; modules a cell
|
|
163
|
+
already imported stay cached until `reset`.
|
|
164
|
+
|
|
165
|
+
Packages go into the session's own environment, never into the interpreter's
|
|
166
|
+
site-packages or the user site: pip always runs as
|
|
167
|
+
`<kernel interpreter> -m pip install --target <environment>`, and flags that pick
|
|
168
|
+
another destination (`--target`, `--user`, `--prefix`, `--root`, `--home`,
|
|
169
|
+
`-e`) are refused. Each install builds a new revision of the environment and
|
|
170
|
+
publishes it only when pip succeeds, so a failed or cancelled install leaves the
|
|
171
|
+
previous packages active and importable. A cell that mixes `%pip` with code is
|
|
172
|
+
refused; put `%pip` on its own cell.
|
|
173
|
+
|
|
174
|
+
`%environment managed` (the default) keeps the environment under the session's
|
|
175
|
+
artifacts directory, or `environments.managedRoot` when set; `%environment project`
|
|
176
|
+
installs into `<cwd>/.senpi/python-packages` instead, shared by every session in
|
|
177
|
+
that project, with concurrent installs serialised by a lock. Setting
|
|
178
|
+
`environments.autoProvision` to `false` turns installs off
|
|
179
|
+
(`environment_installer_unavailable`). Failures carry pip's error output and one
|
|
180
|
+
of `environment_install_failed`, `environment_install_cancelled`,
|
|
181
|
+
`environment_installer_unavailable` or `environment_resolution_conflict`.
|
|
182
|
+
|
|
183
|
+
### Isolated cells
|
|
184
|
+
|
|
185
|
+
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:
|
|
186
|
+
|
|
187
|
+
- 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`.
|
|
188
|
+
- 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`.
|
|
189
|
+
- `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.
|
|
190
|
+
- 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`.
|
|
191
|
+
- 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.
|
|
192
|
+
- 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).
|
|
193
|
+
|
|
194
|
+
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`.
|
|
195
|
+
|
|
103
196
|
## Settings
|
|
104
197
|
|
|
105
198
|
Configuration is loaded in this order:
|
|
@@ -173,7 +266,7 @@ Configuration is loaded in this order:
|
|
|
173
266
|
| `prompt.advertiseHelpers` | `false` | When `true`, one pointer line to `tool_schema('eval:helpers')` is appended to the eval description. |
|
|
174
267
|
| `kernelTools.enabled` | `true` | Allows cells to define kernel tools (`tool(fn)`, `@tool`). `false` makes them refuse with `tools_unavailable`. |
|
|
175
268
|
|
|
176
|
-
The `languages.pyInterpreter`, `environments.*`, `isolation.*`, `
|
|
269
|
+
The `languages.pyInterpreter`, `environments.*`, `isolation.*`, `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.
|
|
177
270
|
|
|
178
271
|
`SENPI_CODEMODE_PY`, `SENPI_CODEMODE_JS`, `SENPI_CODEMODE_RB`, and
|
|
179
272
|
`SENPI_CODEMODE_JL` override the corresponding file setting. `1` or `true`
|
|
@@ -207,7 +300,7 @@ options object and asynchronous helpers are `await`-able.
|
|
|
207
300
|
| `agent(prompt, ...)` | Delegates to the configured active `taskTools.task` tool. Supports background handles and structured JSON results. |
|
|
208
301
|
| `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")`. |
|
|
209
302
|
| `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")`. |
|
|
210
|
-
| `workpool(agent, name, mode?)` | Creates a thin adapter over the normal host `workpool` tool; exposes `pool_id`, `push(items)`, `close()`, `inspect()`, and `cancel()`. JS awaits creation and operations. |
|
|
303
|
+
| `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. |
|
|
211
304
|
| `output(ids, format?, offset?, limit?)` | Delegates transcript retrieval to the configured active `taskTools.output` tool. |
|
|
212
305
|
| `parallel(thunks)` | Runs thunks through the configured bounded pool while preserving input order. |
|
|
213
306
|
| `pipeline(items, ...stages)` | Applies stages left to right with a barrier between stages. |
|
|
@@ -256,8 +349,11 @@ Any isolation metadata supplied by the host on a handle is preserved as
|
|
|
256
349
|
`{subagent_type, prompt, model?}` as its plain-data agent spec. Mode is `fresh`
|
|
257
350
|
or `keep_alive`: pass `{mode: "fresh"}` in JS, `mode="fresh"` in Python/Julia,
|
|
258
351
|
or `mode: "fresh"` in Ruby. Omission is forwarded unchanged to the engine;
|
|
259
|
-
hosts without an approved default still require an explicit mode.
|
|
260
|
-
names
|
|
352
|
+
hosts without an approved default still require an explicit mode. `tools`
|
|
353
|
+
(kernel-tool names, for example from Python `@tool` or JavaScript `tool(fn)`) is
|
|
354
|
+
forwarded unchanged to the host's `create`, which validates it: `{tools: ["add"]}`
|
|
355
|
+
in JS, `tools=["add"]` in Python/Julia, `tools: ["add"]` in Ruby. There is no
|
|
356
|
+
`pool.wait()`; the host delivers the aggregate.
|
|
261
357
|
|
|
262
358
|
`push` forwards `[{key, input}]` and returns the host receipt without waiting
|
|
263
359
|
for admission. Operations return the same `{text, details, images?, hasError?}`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@code-yeongyu/senpi-codemode",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.10",
|
|
4
4
|
"description": "Source-only senpi extension package for codemode evaluation tools",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -32,15 +32,15 @@
|
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@babel/parser": "8.0.4",
|
|
35
|
-
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.
|
|
35
|
+
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.10",
|
|
36
36
|
"quickjs-wasi": "3.6.2",
|
|
37
37
|
"typebox": "1.3.34"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
|
-
"@code-yeongyu/senpi": "2026.10.
|
|
40
|
+
"@code-yeongyu/senpi": "2026.10.10"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
|
-
"@code-yeongyu/senpi": "2026.10.
|
|
43
|
+
"@code-yeongyu/senpi": "2026.10.10"
|
|
44
44
|
},
|
|
45
45
|
"keywords": [
|
|
46
46
|
"senpi",
|
|
@@ -8,6 +8,8 @@ const LOOPBACK_HOST = "127.0.0.1";
|
|
|
8
8
|
|
|
9
9
|
export interface BridgeHttpCallRequest {
|
|
10
10
|
callId: string;
|
|
11
|
+
/** The calling run's secret; the host resolves that run's kernel-tools capability from it. */
|
|
12
|
+
cellToken?: string;
|
|
11
13
|
toolName: string;
|
|
12
14
|
args: unknown;
|
|
13
15
|
signal: AbortSignal;
|
|
@@ -132,7 +134,13 @@ async function dispatchCall(body: unknown, options: BridgeServerOptions, signal:
|
|
|
132
134
|
try {
|
|
133
135
|
return {
|
|
134
136
|
ok: true,
|
|
135
|
-
value: await options.onCall({
|
|
137
|
+
value: await options.onCall({
|
|
138
|
+
callId: body.callId,
|
|
139
|
+
...(typeof body.cellToken === "string" ? { cellToken: body.cellToken } : {}),
|
|
140
|
+
toolName: body.toolName,
|
|
141
|
+
args: body.args,
|
|
142
|
+
signal,
|
|
143
|
+
}),
|
|
136
144
|
};
|
|
137
145
|
} catch (error) {
|
|
138
146
|
return { ok: false, error: bridgeError(error) };
|
package/src/bridge/protocol.ts
CHANGED
|
@@ -44,6 +44,10 @@ const hostToKernelMessageSchema = Type.Union([
|
|
|
44
44
|
cellId: Type.String({ minLength: 1 }),
|
|
45
45
|
code: Type.String(),
|
|
46
46
|
timeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
47
|
+
/** Python only: the published environment revision to import from, applied before the cell runs; empty removes the previous one. */
|
|
48
|
+
envRoot: Type.Optional(Type.String()),
|
|
49
|
+
/** The file a `%load` cell runs; the runner compiles under its name and imports beside it. */
|
|
50
|
+
sourceFile: Type.Optional(Type.String({ minLength: 1 })),
|
|
47
51
|
/** Python only: active tool globals to install before the cell and deactivated ones to remove. */
|
|
48
52
|
preludes: Type.Optional(
|
|
49
53
|
Type.Object({
|
|
@@ -52,6 +52,7 @@ const helpersEntry: EvalSchemaResult = {
|
|
|
52
52
|
"control.status() goes through the host subscription; control.send/cancel/output are fenced by owner, id and run_epoch inside the task owner (stale -> eval_handle_stale, foreign -> eval_handle_forbidden); control.output() returns only that epoch's transcript. The plain output() helper and task tools are unchanged.",
|
|
53
53
|
"The legacy agent(..., {handle: true}) record is unchanged: .output stays a string and no enumerable key is added. A saved {kind, id, run_epoch} (or the record itself) can be rebound with handle(ref) in a later cell or after a kernel reset while the owner and run epoch are valid.",
|
|
54
54
|
"completion(prompt, {handle: true}) (py: handle=True) returns a control handle after preflight validation; provider failures surface in its outcome; its deadline derives from the creating cell's hard deadline. Completion handles are codemode-owned and need no host capability; once the session generation is dropped they fail with eval_handle_stale.",
|
|
55
|
+
"workpool(agent, name, {mode?, tools?}) (py/jl: mode=, tools=; rb: mode:, tools:) forwards tools (kernel-tool names, e.g. from @tool or tool(fn)) unchanged to the host workpool tool's create; the host checks them. There is no pool.wait(): the host delivers the aggregate and the kernel stays free to serve kernel tools.",
|
|
55
56
|
"See tool_schema('eval:wait') for the barrier semantics.",
|
|
56
57
|
].join("\n"),
|
|
57
58
|
parameters: {
|
|
@@ -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
|
+
}
|