@code-yeongyu/senpi-codemode 2026.10.8 → 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.
package/CHANGELOG.md CHANGED
@@ -12,6 +12,26 @@
12
12
 
13
13
  ### Removed
14
14
 
15
+ ## [2026.10.9] - 2026-10-04
16
+
17
+ ### Breaking Changes
18
+
19
+ ### Added
20
+
21
+ - 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)).
22
+ - `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)).
23
+ - 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)).
24
+ - 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)).
25
+
26
+ ### Changed
27
+
28
+ ### Fixed
29
+
30
+ - 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.
31
+ - `%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.
32
+
33
+ ### Removed
34
+
15
35
  ## [2026.10.8] - 2026-10-04
16
36
 
17
37
  ### Breaking Changes
package/README.md CHANGED
@@ -100,6 +100,51 @@ getter and may name a file that does not exist yet. It honors session-directory
100
100
  and in-memory sessions; it cannot be derived reliably from `PI_SESSION_FILE`. If the host
101
101
  omits the getter, the variable is unset rather than inherited from the launching process.
102
102
 
103
+ ### Loading a file
104
+
105
+ A Python or JavaScript cell whose only line is `%load <path>` reads that file and
106
+ runs it as the cell, so its definitions persist like any cell. The path is relative
107
+ to the session directory, or a `local://` or `file://` URL; other URL schemes are
108
+ refused and nothing is fetched. The cell runs as its file: Python compiles it under
109
+ the file's name, so tracebacks name the file and line, and sets `__file__` and puts
110
+ the file's directory first on the import path, so `from sibling import g` resolves
111
+ next to it. JavaScript evaluates it under the file's name and resolves its relative
112
+ imports from the file's directory. The file is read when the cell's turn comes in
113
+ the kernel's queue, so a `%load` queued behind the cell that writes the file runs
114
+ what that cell wrote. When the cell ends, Python takes the file's directory back off
115
+ the import path and restores `__file__`, so later cells import as before. A file that
116
+ cannot be read fails the cell with a message naming the path as written
117
+ (`file not found: <path>`, `permission denied: <path>`); files over 8 MiB, and
118
+ anything that is not a regular file, are refused.
119
+
120
+ ### Python packages
121
+
122
+ A Python cell whose only line is `%pip install <requirements>` installs packages
123
+ without restarting the kernel: variables, the process and its working directory
124
+ stay as they were. The cell runs in the kernel's queue like any other cell (it waits
125
+ for the cell ahead of it, can be cancelled while queued, and detaches past the
126
+ foreground window), but the install runs on the host, so its time does not count
127
+ against the run budget. The next cell imports the new packages; modules a cell
128
+ already imported stay cached until `reset`.
129
+
130
+ Packages go into the session's own environment, never into the interpreter's
131
+ site-packages or the user site: pip always runs as
132
+ `<kernel interpreter> -m pip install --target <environment>`, and flags that pick
133
+ another destination (`--target`, `--user`, `--prefix`, `--root`, `--home`,
134
+ `-e`) are refused. Each install builds a new revision of the environment and
135
+ publishes it only when pip succeeds, so a failed or cancelled install leaves the
136
+ previous packages active and importable. A cell that mixes `%pip` with code is
137
+ refused; put `%pip` on its own cell.
138
+
139
+ `%environment managed` (the default) keeps the environment under the session's
140
+ artifacts directory, or `environments.managedRoot` when set; `%environment project`
141
+ installs into `<cwd>/.senpi/python-packages` instead, shared by every session in
142
+ that project, with concurrent installs serialised by a lock. Setting
143
+ `environments.autoProvision` to `false` turns installs off
144
+ (`environment_installer_unavailable`). Failures carry pip's error output and one
145
+ of `environment_install_failed`, `environment_install_cancelled`,
146
+ `environment_installer_unavailable` or `environment_resolution_conflict`.
147
+
103
148
  ## Settings
104
149
 
105
150
  Configuration is loaded in this order:
@@ -207,7 +252,7 @@ options object and asynchronous helpers are `await`-able.
207
252
  | `agent(prompt, ...)` | Delegates to the configured active `taskTools.task` tool. Supports background handles and structured JSON results. |
208
253
  | `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
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")`. |
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. |
255
+ | `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
256
  | `output(ids, format?, offset?, limit?)` | Delegates transcript retrieval to the configured active `taskTools.output` tool. |
212
257
  | `parallel(thunks)` | Runs thunks through the configured bounded pool while preserving input order. |
213
258
  | `pipeline(items, ...stages)` | Applies stages left to right with a barrier between stages. |
@@ -256,8 +301,11 @@ Any isolation metadata supplied by the host on a handle is preserved as
256
301
  `{subagent_type, prompt, model?}` as its plain-data agent spec. Mode is `fresh`
257
302
  or `keep_alive`: pass `{mode: "fresh"}` in JS, `mode="fresh"` in Python/Julia,
258
303
  or `mode: "fresh"` in Ruby. Omission is forwarded unchanged to the engine;
259
- hosts without an approved default still require an explicit mode. Custom tool
260
- names are not enabled by this adapter.
304
+ hosts without an approved default still require an explicit mode. `tools`
305
+ (kernel-tool names, for example from Python `@tool` or JavaScript `tool(fn)`) is
306
+ forwarded unchanged to the host's `create`, which validates it: `{tools: ["add"]}`
307
+ in JS, `tools=["add"]` in Python/Julia, `tools: ["add"]` in Ruby. There is no
308
+ `pool.wait()`; the host delivers the aggregate.
261
309
 
262
310
  `push` forwards `[{key, input}]` and returns the host receipt without waiting
263
311
  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.8",
3
+ "version": "2026.10.9",
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.8",
35
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.9",
36
36
  "quickjs-wasi": "3.6.2",
37
37
  "typebox": "1.3.34"
38
38
  },
39
39
  "peerDependencies": {
40
- "@code-yeongyu/senpi": "2026.10.8"
40
+ "@code-yeongyu/senpi": "2026.10.9"
41
41
  },
42
42
  "devDependencies": {
43
- "@code-yeongyu/senpi": "2026.10.8"
43
+ "@code-yeongyu/senpi": "2026.10.9"
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({ callId: body.callId, toolName: body.toolName, args: body.args, signal }),
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) };
@@ -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: {
@@ -61,11 +61,71 @@ const SHORT_ALIASES: Readonly<Record<string, string>> = {
61
61
  const STDERR_TAIL_BYTES = 4_096;
62
62
  const PIP_TREE_GRACE_MS = 2_000;
63
63
 
64
+ /**
65
+ * Splits a %pip argument line the way the platform's shell would for these inputs: single and double quotes
66
+ * group (`"pkg[extra]>=1.0"`) and `#` at the start of a word begins a comment. A backslash escapes the next
67
+ * character only in POSIX mode; on Windows it is a path separator and stays as written. An unclosed quote is
68
+ * refused rather than guessed.
69
+ */
70
+ const DOUBLE_QUOTE_ESCAPES: ReadonlySet<string> = new Set(['"', "\\", "$", "`"]);
71
+
72
+ export function splitShellWords(text: string, posix = process.platform !== "win32"): string[] {
73
+ const words: string[] = [];
74
+ let word = "";
75
+ let inWord = false;
76
+ let quote: "'" | '"' | undefined;
77
+ for (let index = 0; index < text.length; index++) {
78
+ const char = text[index] ?? "";
79
+ if (quote === "'") {
80
+ if (char === "'") quote = undefined;
81
+ else word += char;
82
+ continue;
83
+ }
84
+ if (posix && char === "\\" && index + 1 < text.length) {
85
+ const next = text[index + 1] ?? "";
86
+ // A backslash-newline is a line continuation in a shell, inside double quotes or not: both characters go.
87
+ if (next === "\n") {
88
+ index += 1;
89
+ continue;
90
+ }
91
+ // Inside double quotes a shell only escapes these; any other backslash stays as written.
92
+ if (quote === '"' && !DOUBLE_QUOTE_ESCAPES.has(next)) {
93
+ word += char;
94
+ continue;
95
+ }
96
+ index += 1;
97
+ word += next;
98
+ inWord = true;
99
+ continue;
100
+ }
101
+ if (quote === '"') {
102
+ if (char === '"') quote = undefined;
103
+ else word += char;
104
+ continue;
105
+ }
106
+ if (char === "'" || char === '"') {
107
+ quote = char;
108
+ inWord = true;
109
+ continue;
110
+ }
111
+ if (/\s/u.test(char)) {
112
+ if (inWord) words.push(word);
113
+ word = "";
114
+ inWord = false;
115
+ continue;
116
+ }
117
+ if (char === "#" && !inWord) break;
118
+ word += char;
119
+ inWord = true;
120
+ }
121
+ if (quote !== undefined)
122
+ throw new EnvironmentError("environment_install_failed", `unclosed ${quote} quote in %pip arguments`);
123
+ if (inWord) words.push(word);
124
+ return words;
125
+ }
126
+
64
127
  export function parsePipRequirements(text: string): string[] {
65
- const args = text
66
- .trim()
67
- .split(/\s+/)
68
- .filter((arg) => arg !== "");
128
+ const args = splitShellWords(text);
69
129
  const command = args[0] === "install" ? args.slice(1) : undefined;
70
130
  if (command === undefined) {
71
131
  throw new EnvironmentError("environment_install_failed", "only `%pip install <requirements>` is supported");
@@ -0,0 +1,85 @@
1
+ import type { ResolvedCodemodeSettings } from "../config/settings.ts";
2
+ import {
3
+ type EnvironmentMode,
4
+ type InstallReceipt,
5
+ installPythonPackages,
6
+ managedEnvironmentBase,
7
+ projectPythonBase,
8
+ pythonAbiTag,
9
+ } from "./py-environment.ts";
10
+ import { EnvironmentError } from "./py-installer.ts";
11
+ import { readActiveRevisionSync } from "./revision-store.ts";
12
+
13
+ export interface PythonEnvironmentsOptions {
14
+ readonly artifactsDir: string;
15
+ readonly cwd: string;
16
+ readonly interpreter: string;
17
+ readonly settings: Pick<ResolvedCodemodeSettings, "environments">;
18
+ }
19
+
20
+ export class PythonEnvironments {
21
+ readonly #options: PythonEnvironmentsOptions;
22
+ #mode: EnvironmentMode = "managed";
23
+ #currentBase: string | undefined;
24
+ #managedBase: Promise<string> | undefined;
25
+
26
+ constructor(options: PythonEnvironmentsOptions) {
27
+ this.#options = options;
28
+ }
29
+
30
+ get mode(): EnvironmentMode {
31
+ return this.#mode;
32
+ }
33
+
34
+ /**
35
+ * The revision a Python cell imports from, read from the current mode's root each time a cell starts, so an
36
+ * install by another session sharing the project root is seen. Undefined while nothing is installed there.
37
+ */
38
+ get activeRoot(): string | undefined {
39
+ return this.#currentBase === undefined ? undefined : readActiveRevisionSync(this.#currentBase)?.dir;
40
+ }
41
+
42
+ async setMode(mode: EnvironmentMode): Promise<string> {
43
+ const base = await this.#base(mode);
44
+ this.#mode = mode;
45
+ this.#currentBase = base;
46
+ return base;
47
+ }
48
+
49
+ async install(
50
+ requirements: string,
51
+ signal: AbortSignal,
52
+ onOutput?: (stream: "stdout" | "stderr", data: string) => void,
53
+ ): Promise<InstallReceipt> {
54
+ if (this.#options.settings.environments?.autoProvision === false) {
55
+ throw new EnvironmentError(
56
+ "environment_installer_unavailable",
57
+ "installs are turned off for this project (environments.autoProvision is false)",
58
+ );
59
+ }
60
+ const mode = this.#mode;
61
+ const base = await this.#base(mode);
62
+ const receipt = await installPythonPackages({
63
+ base,
64
+ mode,
65
+ interpreter: this.#options.interpreter,
66
+ requirements,
67
+ cwd: this.#options.cwd,
68
+ signal,
69
+ ...(onOutput === undefined ? {} : { onOutput }),
70
+ });
71
+ if (this.#mode === mode) this.#currentBase = base;
72
+ return receipt;
73
+ }
74
+
75
+ #base(mode: EnvironmentMode): Promise<string> {
76
+ if (mode === "project") return Promise.resolve(projectPythonBase(this.#options.cwd));
77
+ const configured = this.#options.settings.environments?.managedRoot;
78
+ this.#managedBase ??= pythonAbiTag(this.#options.interpreter).then((abi) =>
79
+ configured === undefined
80
+ ? managedEnvironmentBase(this.#options.artifactsDir, "py", abi)
81
+ : managedEnvironmentBase(configured, "py", abi),
82
+ );
83
+ return this.#managedBase;
84
+ }
85
+ }
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
2
3
  import { cp, mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
4
  import { join } from "node:path";
4
5
  import { type LockWaitNotice, withRootLock } from "./install-lock.ts";
@@ -12,11 +13,25 @@ export interface Revision {
12
13
  readonly dir: string;
13
14
  }
14
15
 
16
+ /** The same read, synchronous: the pointer is one short file, read when a Python cell starts running. */
17
+ export function readActiveRevisionSync(base: string): Revision | undefined {
18
+ try {
19
+ return parsePointer(base, readFileSync(join(base, POINTER), "utf8"));
20
+ } catch (error) {
21
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined;
22
+ throw error;
23
+ }
24
+ }
25
+
26
+ function parsePointer(base: string, text: string): Revision | undefined {
27
+ const name = text.trim();
28
+ const match = REVISION.exec(name);
29
+ return match?.[1] === undefined ? undefined : { number: Number(match[1]), dir: join(base, name) };
30
+ }
31
+
15
32
  export async function readActiveRevision(base: string): Promise<Revision | undefined> {
16
33
  try {
17
- const name = (await readFile(join(base, POINTER), "utf8")).trim();
18
- const match = REVISION.exec(name);
19
- return match?.[1] === undefined ? undefined : { number: Number(match[1]), dir: join(base, name) };
34
+ return parsePointer(base, await readFile(join(base, POINTER), "utf8"));
20
35
  } catch (error) {
21
36
  if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined;
22
37
  throw error;
@@ -8,6 +8,8 @@ export interface BridgeToolCallRequest {
8
8
  readonly toolName: string;
9
9
  readonly args: unknown;
10
10
  readonly callId: string;
11
+ /** The calling run's secret; absent for a call made outside any run (a plain thread), which gets no kernel tools. */
12
+ readonly cellToken?: string;
11
13
  readonly signal: AbortSignal;
12
14
  }
13
15
 
@@ -0,0 +1,28 @@
1
+ import { kernelToolsStorage } from "@code-yeongyu/senpi";
2
+ import type { KernelToolsCapability } from "../kernels/js/kernel-tools-types.ts";
3
+
4
+ /**
5
+ * Kernel-tools capabilities of the subprocess-kernel cells running right now, keyed by a secret minted per run and
6
+ * sent only to the kernel running that cell. Those kernels (py/rb/jl) reach the host over the bridge, outside the
7
+ * submitting cell's async context, so a bridge call carries its run's secret and runs inside that run's capability:
8
+ * the same `kernelToolsStorage` scope the JS worker path enters per host call (#1754).
9
+ */
10
+ export class CellKernelTools {
11
+ readonly #byToken = new Map<string, KernelToolsCapability>();
12
+
13
+ /** Makes `capability` the kernel tools of calls carrying `token` until the returned release runs. */
14
+ bind(token: string, capability: KernelToolsCapability): () => void {
15
+ this.#byToken.set(token, capability);
16
+ return () => {
17
+ if (this.#byToken.get(token) === capability) this.#byToken.delete(token);
18
+ };
19
+ }
20
+
21
+ /** Runs `call` with the capability bound to `token`; an absent, unknown or released token gets none. */
22
+ async run<T>(token: string | undefined, call: () => Promise<T>): Promise<T> {
23
+ const capability = token === undefined ? undefined : this.#byToken.get(token);
24
+ return capability === undefined
25
+ ? await kernelToolsStorage.exit(call)
26
+ : await kernelToolsStorage.run(capability, call);
27
+ }
28
+ }
@@ -13,6 +13,7 @@ import type {
13
13
  KernelInterruptHandle,
14
14
  PendingCell,
15
15
  } from "../tool/types.ts";
16
+ import { hasKernelTools, type KernelToolsMethods } from "./kernel-tools-probe.ts";
16
17
 
17
18
  export type StartKernel = (lifecycle: Required<KernelLifecycle>) => Promise<EvalKernel>;
18
19
 
@@ -50,10 +51,29 @@ export class ReplaceableKernel implements EvalKernel {
50
51
  #closed = false;
51
52
  #closeFailure: unknown;
52
53
 
54
+ // Present only when the instance has kernel tools, so a probe sees what it would see on the instance itself.
55
+ declare readonly describeKernelTools?: KernelToolsMethods["describeKernelTools"];
56
+ declare readonly invokeKernelTool?: KernelToolsMethods["invokeKernelTool"];
57
+
53
58
  private constructor(language: EvalLanguage, start: StartKernel, first: EvalKernel) {
54
59
  this.#language = language;
55
60
  this.#start = start;
56
61
  this.#kernel = first;
62
+ // Python is the one subprocess kernel with kernel tools; rb and jl only carry tools_unavailable stubs to hide.
63
+ if (language === "py" && hasKernelTools(first)) {
64
+ // Always the CURRENT instance: after a replacement the old definitions are gone with the old interpreter.
65
+ const tools: KernelToolsMethods = {
66
+ describeKernelTools: (names) => this.#currentTools().describeKernelTools(names),
67
+ invokeKernelTool: (request, options) => this.#currentTools().invokeKernelTool(request, options),
68
+ };
69
+ Object.assign(this, tools);
70
+ }
71
+ }
72
+
73
+ #currentTools(): KernelToolsMethods {
74
+ const current = this.#kernel;
75
+ if (!hasKernelTools(current)) throw new Error(`The ${this.#language} kernel has no kernel tools`);
76
+ return current;
57
77
  }
58
78
 
59
79
  /** Starts the first instance; one that cannot hand back its queue is returned as it is. */
@@ -8,6 +8,7 @@ import {
8
8
  type ResolvedCodemodeSettings,
9
9
  resolveEnabledLanguages,
10
10
  } from "../config/settings.ts";
11
+ import { PythonEnvironments } from "../environments/python-environments.ts";
11
12
  import type { HandleRegistry } from "../handles/handle-registry.ts";
12
13
  import {
13
14
  createInterpreterDetector,
@@ -47,6 +48,7 @@ export type SessionRuntime = {
47
48
  readonly artifactsDir: string;
48
49
  readonly executeTool: AgentExecuteTool;
49
50
  readonly spawns: boolean;
51
+ readonly pythonEnvironments?: PythonEnvironments;
50
52
  };
51
53
 
52
54
  export async function createRuntime(
@@ -96,6 +98,16 @@ export async function createRuntime(
96
98
  artifactsDir: artifacts.dir,
97
99
  executeTool,
98
100
  spawns: activeTools.has(settings.taskTools.task),
101
+ ...(availability.py.detected.ok && enabledLanguages.py
102
+ ? {
103
+ pythonEnvironments: new PythonEnvironments({
104
+ artifactsDir: artifacts.dir,
105
+ cwd: ctx.cwd,
106
+ interpreter: availability.py.detected.path,
107
+ settings,
108
+ }),
109
+ }
110
+ : {}),
99
111
  };
100
112
  }
101
113
 
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionContext } from "@code-yeongyu/senpi";
2
2
  import type { KernelToHostMessage } from "../bridge/protocol.ts";
3
3
  import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
4
+ import type { KernelToolsCapability } from "../kernels/js/kernel-tools-types.ts";
4
5
  import type { EvalKernel, EvalLanguage } from "../tool/types.ts";
5
6
  import {
6
7
  CodemodeSessionDisposedError,
@@ -106,6 +107,11 @@ export class SessionManagerProxy implements CodemodeSessionManager, EvalExecutio
106
107
  this.#current?.releaseKernelListener?.(language, onMessage);
107
108
  }
108
109
 
110
+ bindCellKernelTools(token: string, capability: KernelToolsCapability): () => void {
111
+ // Bound to the generation that runs the cell: a later generation never serves that cell's host calls.
112
+ return this.#current?.bindCellKernelTools?.(token, capability) ?? (() => undefined);
113
+ }
114
+
109
115
  async complete(request: CompletionRequest, ctx: ExtensionContext): Promise<CompletionResult> {
110
116
  this.assertEvalExecutionAllowed();
111
117
  const current = this.#current;
@@ -6,11 +6,12 @@ import type { CompletionRequest, CompletionResult } from "../completion/handler.
6
6
  import { resolveKernelMemoryThresholds } from "../config/memory-settings.ts";
7
7
  import { collectOrphanedChildren } from "../host-sdk.ts";
8
8
  import { JavaScriptKernel } from "../kernels/js/context-manager.ts";
9
- import type { KernelToolsDescribeResult } from "../kernels/js/kernel-tools-types.ts";
9
+ import type { KernelToolsCapability, KernelToolsDescribeResult } from "../kernels/js/kernel-tools-types.ts";
10
10
  import type { KernelLifecycle } from "../kernels/shared/kernel-death.ts";
11
11
  import type { EvalKernel, EvalLanguage } from "../tool/types.ts";
12
12
  import { type BridgeDispatchContext, dispatchBridgeCompletion } from "./bridge-dispatch.ts";
13
13
  import { type BridgeToolCallRequest, routeBridgeToolCall } from "./bridge-tool-call.ts";
14
+ import { CellKernelTools } from "./cell-kernel-tools.ts";
14
15
  import { parkWhenIdle } from "./idle-parking-kernel.ts";
15
16
  import {
16
17
  javaScriptKernelMemory,
@@ -65,6 +66,7 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
65
66
  readonly #registrations: SessionKernelRegistrations;
66
67
  #kernelCreations = new Map<EvalLanguage, Promise<EvalKernel>>();
67
68
  #onMessageRefs = new Map<EvalLanguage, (message: KernelToHostMessage) => void>();
69
+ readonly #cellKernelTools = new CellKernelTools();
68
70
  #context: ExtensionContext | undefined;
69
71
  #generation = 0;
70
72
  #disposePromise: Promise<void> | undefined;
@@ -91,7 +93,13 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
91
93
  }
92
94
 
93
95
  async #call(request: BridgeToolCallRequest): Promise<unknown> {
94
- return await routeBridgeToolCall(this.#options, request, this.#context?.evalHandleHost);
96
+ return await this.#cellKernelTools.run(request.cellToken, () =>
97
+ routeBridgeToolCall(this.#options, request, this.#context?.evalHandleHost),
98
+ );
99
+ }
100
+
101
+ bindCellKernelTools(token: string, capability: KernelToolsCapability): () => void {
102
+ return this.#cellKernelTools.bind(token, capability);
95
103
  }
96
104
 
97
105
  async getKernel(language: EvalLanguage, onMessage: (message: KernelToHostMessage) => void): Promise<EvalKernel> {
package/src/index.ts CHANGED
@@ -157,6 +157,7 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
157
157
  complete,
158
158
  settings: runtime.settings,
159
159
  artifactsDir: runtime.artifactsDir,
160
+ ...(runtime.pythonEnvironments === undefined ? {} : { pythonEnvironments: runtime.pythonEnvironments }),
160
161
  cellManager,
161
162
  handles,
162
163
  executionTracker: manager,
@@ -210,9 +210,14 @@ function Base.getproperty(pool::SenpiWorkpool, name::Symbol)
210
210
  getfield(pool, name)
211
211
  end
212
212
 
213
- function workpool(agent::AbstractDict, name::AbstractString; mode=nothing)
213
+ function workpool(agent::AbstractDict, name::AbstractString; mode=nothing, tools=nothing)
214
214
  args = Dict{String, Any}("op" => "create", "agent" => agent, "name" => name)
215
215
  mode !== nothing && (args["mode"] = mode)
216
+ if tools !== nothing
217
+ (tools isa AbstractVector && all(name -> name isa AbstractString, tools)) ||
218
+ throw(SenpiBridgeError("workpool(tools=...) takes a vector of tool names; got $(typeof(tools))", "invalid_tools"))
219
+ args["tools"] = collect(String, tools)
220
+ end
216
221
  result = senpi_workpool_call(args)
217
222
  details = get(result, "details", nothing)
218
223
  if details isa AbstractDict
@@ -1,6 +1,7 @@
1
1
  import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
2
2
  import { CHILD_LIFECYCLE_OP, INTERRUPT_ACK_OP } from "../../bridge/reserved.ts";
3
3
  import type { KernelInterruptHandle } from "../../tool/types.ts";
4
+ import { inputAtStart } from "../shared/cell-source-at-start.ts";
4
5
  import { KernelToolHostPump } from "../shared/kernel-tools-pump.ts";
5
6
  import { ActiveCellControl } from "./active-cell-control.ts";
6
7
  import { DEFAULT_INTERRUPT_BOUNDS, type WorkerRetirement } from "./interrupt-bounds.ts";
@@ -195,6 +196,19 @@ export class JavaScriptKernel {
195
196
  if (this.#lifecycle !== "open" || this.#runs.active || !this.#slot.present) return;
196
197
  const next = this.#runs.startNext(performance.now());
197
198
  if (!next) return;
199
+ const input = inputAtStart(next.input);
200
+ if ("refused" in input) {
201
+ this.#runs.releaseActive(next);
202
+ this.#runs.settle(next, {
203
+ type: "result",
204
+ cellId: next.input.cellId,
205
+ ok: false,
206
+ error: { message: input.refused },
207
+ durationMs: 0,
208
+ });
209
+ this.#startNext();
210
+ return;
211
+ }
198
212
  this.#activeCell.arm(next);
199
213
  this.#slot.postMessage({
200
214
  type: "kernel-tools-names",
@@ -204,7 +218,7 @@ export class JavaScriptKernel {
204
218
  this.#slot.postMessage({
205
219
  type: "run",
206
220
  cellId: next.input.cellId,
207
- code: this.#moduleLoader.prepareCell(next.input.code, next.input.kernelPreludes),
221
+ code: this.#moduleLoader.prepareCell(input.code, input.kernelPreludes, input.sourceFile),
208
222
  timeoutMs: next.input.timeoutMs,
209
223
  });
210
224
  }
@@ -1,4 +1,4 @@
1
- import { join, resolve, sep } from "node:path";
1
+ import { dirname, join, resolve, sep } from "node:path";
2
2
  import { pathToFileURL } from "node:url";
3
3
  import type { KernelPreludeContribution } from "@code-yeongyu/senpi";
4
4
  import type { BridgeConnectionConfig } from "../../bridge/protocol.ts";
@@ -121,8 +121,13 @@ export class LocalModuleLoader {
121
121
  this.#prelude = loaderPrelude(runtimeContext(options));
122
122
  }
123
123
 
124
- prepareCell(code: string, contributions: readonly KernelPreludeContribution[] = []): string {
125
- const prelude = `${this.#prelude}\n${contributionPrelude(this.#contributions.plan(contributions))}`;
126
- return `${PREPARED_CELL_PREFIX}${JSON.stringify({ prelude, code: rewriteImports(code) })}`;
124
+ prepareCell(code: string, contributions: readonly KernelPreludeContribution[] = [], sourceFile?: string): string {
125
+ // A %load cell resolves its relative imports from its own file's directory for that cell only.
126
+ const base =
127
+ sourceFile === undefined
128
+ ? ""
129
+ : `\nglobalThis.__senpi_module_context__ = { ...globalThis.__senpi_module_context__, cwdUrl: ${JSON.stringify(directoryUrl(dirname(sourceFile)))} };`;
130
+ const prelude = `${this.#prelude}${base}\n${contributionPrelude(this.#contributions.plan(contributions))}`;
131
+ return `${PREPARED_CELL_PREFIX}${JSON.stringify({ prelude, code: rewriteImports(code), ...(sourceFile === undefined ? {} : { sourceFile }) })}`;
127
132
  }
128
133
  }
@@ -65,13 +65,15 @@ export class JsWorkerRuntime {
65
65
  try {
66
66
  let prelude = "";
67
67
  let cellCode = code;
68
+ let sourceName = cellId;
68
69
  if (code.startsWith(PREPARED_CELL_PREFIX)) {
69
70
  const prepared = JSON.parse(code.slice(PREPARED_CELL_PREFIX.length));
70
71
  if (!isPlainObject(prepared) || typeof prepared.prelude !== "string" || typeof prepared.code !== "string") throw new Error("Invalid prepared JavaScript cell payload");
71
72
  ({ prelude, code: cellCode } = prepared);
73
+ if (typeof prepared.sourceFile === "string") sourceName = prepared.sourceFile;
72
74
  }
73
75
  if (prelude) indirectEval(prelude, PRELUDE_SOURCE_URL);
74
- const value = await awaitMaybePromise(indirectEval(bindKernelBun(wrapUserCode(cellCode)), cellId));
76
+ const value = await awaitMaybePromise(indirectEval(bindKernelBun(wrapUserCode(cellCode)), sourceName));
75
77
  await this.#drainPendingDisplays();
76
78
  return value;
77
79
  } finally {