@code-yeongyu/senpi-codemode 2026.9.6 → 2026.9.7-2

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,35 @@
12
12
 
13
13
  ### Removed
14
14
 
15
+ ## [2026.9.7-2] - 2026-09-07
16
+
17
+ ### Breaking Changes
18
+
19
+ ### Added
20
+
21
+ ### Changed
22
+
23
+ ### Fixed
24
+
25
+ - The JS kernel's shell capture now pins the worker's environment view for `Bun.spawnSync` as well as `Bun.spawn`, so a cell calling it without an explicit `env` sees the session's `PI_*` values instead of the inherited OS environ.
26
+ - Eval kernels and every child they spawn now see the active session's `PI_*` environment (`PI_SESSION_ID`, `PI_SESSION_FILE`, `PI_PROVIDER`, `PI_MODEL`, `PI_REASONING_LEVEL`) exactly as bash-tool children do: inherited `PI_*` values are dropped before the session values are applied, so subprocesses such as `omo-agent-toolkit ulw-loop` resolve the same session as the `bash` tool instead of a cwd-global one.
27
+ - JavaScript eval cells no longer lose their completion value when a nested function, callback, or try/catch helper contains `return`: the cell wrapper now skips last-expression capture only for a genuine top-level `return`, and a property named `return` no longer primes the statement scanner as the keyword (#1439).
28
+ - Eval output truncation notices now name the real cause: a width-clamped line reports `N line(s) clamped to M columns (… dropped)`, a byte-capped tail reports the actual cap, and a notice never presents the output's own size as a limit.
29
+
30
+ ### Removed
31
+
32
+ ## [2026.9.7] - 2026-09-07
33
+
34
+ ### Breaking Changes
35
+
36
+ ### Added
37
+
38
+ ### Changed
39
+
40
+ ### Fixed
41
+
42
+ ### Removed
43
+
15
44
  ## [2026.9.6] - 2026-09-06
16
45
 
17
46
  ### Breaking Changes
package/README.md CHANGED
@@ -54,6 +54,19 @@ task-tool names are known.
54
54
  A missing optional interpreter removes that language from the session's `eval`
55
55
  schema; it is not an installation failure.
56
56
 
57
+ ### Session environment
58
+
59
+ Every kernel starts with the active session's `PI_*` environment — `PI_SESSION_ID`,
60
+ `PI_SESSION_FILE` (when the session is persistent), `PI_PROVIDER`, `PI_MODEL`, and
61
+ `PI_REASONING_LEVEL` (when set) — resolved at session start, mirroring the bash tool's
62
+ session environment contract. The values are visible to `env()`/`process.env`/`os.environ`
63
+ inside cells and are inherited by every child process a cell spawns
64
+ (`Bun.$`, `Bun.spawn`, `child_process`, `subprocess`, ...). Inherited `PI_*` values from
65
+ the launching environment are dropped first, so a child spawned from a cell sees exactly
66
+ what a child spawned from the bash tool sees. The values snapshot at kernel start, so a
67
+ mid-session model switch updates the bash tool's next command but not already-running
68
+ kernels; a new session starts fresh kernels with fresh values.
69
+
57
70
  ## Settings
58
71
 
59
72
  Configuration is loaded in this order:
@@ -116,7 +129,7 @@ options object and asynchronous helpers are `await`-able.
116
129
  | `print(value, ...)` | Emits text output. |
117
130
  | `read(path, offset?, limit?)` | Reads text with 1-indexed line slicing. `local://` paths resolve under the session artifact root. |
118
131
  | `write(path, content)` | Creates parent directories and writes text. `local://` paths persist in the session artifact root. |
119
- | `env(key?, value?)` | Reads all kernel environment values, one value, or sets one value. |
132
+ | `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)). |
120
133
  | `tool.<name>(args)` | Invokes an active Senpi tool through the normal `pi.executeTool` pipeline and returns `{ text, images?, details?, hasError? }` in every kernel; image blocks arrive as `images[i] = { mimeType, dataBase64 }`. |
121
134
  | `tool_schema(name?)` | Returns a tool's parameter schema without calling it; omit `name` to list tool names. |
122
135
  | `completion(prompt, model?, system?, schema?)` | Requests a one-shot host completion; `schema` asks the host to parse structured output. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-codemode",
3
- "version": "2026.9.6",
3
+ "version": "2026.9.7-2",
4
4
  "description": "Source-only senpi extension package for codemode evaluation tools",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -30,14 +30,14 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@babel/parser": "8.0.4",
33
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.9.6",
33
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.9.7-2",
34
34
  "typebox": "1.3.18"
35
35
  },
36
36
  "peerDependencies": {
37
- "@code-yeongyu/senpi": "2026.9.6"
37
+ "@code-yeongyu/senpi": "2026.9.7-2"
38
38
  },
39
39
  "devDependencies": {
40
- "@code-yeongyu/senpi": "2026.9.6"
40
+ "@code-yeongyu/senpi": "2026.9.7-2"
41
41
  },
42
42
  "keywords": [
43
43
  "senpi",
@@ -26,6 +26,7 @@ const hostToKernelMessageSchema = Type.Union([
26
26
  type: Type.Literal("init"),
27
27
  sessionId: Type.String({ minLength: 1 }),
28
28
  connection: connectionConfigSchema,
29
+ sessionEnv: Type.Optional(Type.Record(Type.String(), Type.String())),
29
30
  }),
30
31
  Type.Object({
31
32
  type: Type.Literal("run"),
@@ -13,6 +13,7 @@ import {
13
13
  getInterpreterAvailability,
14
14
  type InterpreterAvailability,
15
15
  } from "../interpreters/detect.ts";
16
+ import { sessionEnvironmentFrom } from "../kernels/session-env.ts";
16
17
  import { resolveSessionArtifactsDir } from "../output/streaming-output.ts";
17
18
  import type { EnabledEvalLanguages, EvalLanguage, EvalRuntimes } from "../tool/types.ts";
18
19
  import { jsRuntimeInfo, runtimesFromAvailability } from "./runtime-info.ts";
@@ -66,11 +67,13 @@ export async function createRuntime(
66
67
  const executeTool = createExecuteTool(pi, activeTools);
67
68
  const create = options.createSessionManager ?? createCodemodeSessionManager;
68
69
  const sessionId = sessionIdFrom(event);
70
+ const sessionEnv = sessionEnvironmentFrom(ctx);
69
71
  const configuredPoolWidth = settings.parallelPoolWidth;
70
72
  const parallelPoolWidth = Number.isFinite(configuredPoolWidth) ? Math.max(1, Math.trunc(configuredPoolWidth)) : 1;
71
73
  const manager = await create({
72
74
  sessionId,
73
75
  cwd: ctx.cwd,
76
+ sessionEnv,
74
77
  settings,
75
78
  availability,
76
79
  artifactsDir: artifacts.dir,
@@ -11,6 +11,7 @@ import { JuliaKernel } from "../kernels/jl/kernel.ts";
11
11
  import { JavaScriptKernel } from "../kernels/js/context-manager.ts";
12
12
  import { PythonKernel } from "../kernels/py/kernel.ts";
13
13
  import { RubyKernel } from "../kernels/rb/kernel.ts";
14
+ import type { SessionEnvironment } from "../kernels/session-env.ts";
14
15
  import { marshalToolResult } from "../tool/image.ts";
15
16
  import type { EvalKernel, EvalKernelManager, EvalLanguage, ExecuteTool } from "../tool/types.ts";
16
17
 
@@ -40,6 +41,8 @@ export interface CreateCodemodeSessionManagerOptions {
40
41
  readonly localRoots?: Readonly<Record<string, string>>;
41
42
  /** Session-adjacent directory used for persisted eval artifacts. */
42
43
  readonly artifactsDir?: string;
44
+ /** Per-session PI_* values exposed to every kernel and the children it spawns. */
45
+ readonly sessionEnv?: SessionEnvironment;
43
46
  readonly executeTool: ExecuteTool;
44
47
  readonly listTools?: () => readonly EvalSchemaToolInfo[];
45
48
  readonly complete: (request: CompletionRequest, ctx: ExtensionContext) => Promise<CompletionResult>;
@@ -212,6 +215,7 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
212
215
  cwd: this.#options.cwd,
213
216
  parallelPoolWidth,
214
217
  onMessage,
218
+ ...(this.#options.sessionEnv ? { sessionEnv: this.#options.sessionEnv } : {}),
215
219
  ...(localRoots ? { localRoots: { ...localRoots } } : {}),
216
220
  ...(this.#options.artifactsDir ? { artifactsDir: this.#options.artifactsDir } : {}),
217
221
  });
@@ -230,6 +234,7 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
230
234
  interpreterPath: detected.path,
231
235
  sessionId: this.#options.sessionId,
232
236
  cwd: this.#options.cwd,
237
+ ...(this.#options.sessionEnv ? { sessionEnv: this.#options.sessionEnv } : {}),
233
238
  connection,
234
239
  onMessage,
235
240
  });
@@ -239,6 +244,7 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
239
244
  command: detected.path,
240
245
  sessionId: this.#options.sessionId,
241
246
  cwd: this.#options.cwd,
247
+ ...(this.#options.sessionEnv ? { sessionEnv: this.#options.sessionEnv } : {}),
242
248
  connection,
243
249
  onMessage,
244
250
  });
@@ -247,6 +253,7 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
247
253
  command: detected.path,
248
254
  sessionId: this.#options.sessionId,
249
255
  cwd: this.#options.cwd,
256
+ ...(this.#options.sessionEnv ? { sessionEnv: this.#options.sessionEnv } : {}),
250
257
  connection,
251
258
  onMessage,
252
259
  });
@@ -15,6 +15,7 @@ runner/prelude assets).
15
15
  | Ruby kernel | `rb/kernel.ts` + `rb/prelude.rb`, `rb/runner.rb` |
16
16
  | Julia kernel | `jl/kernel.ts` + `jl/prelude.jl`, `jl/runner.jl` |
17
17
  | Shared subprocess layer | `shared/subprocess-kernel.ts`, `subprocess-{contract,process,queue,run}.ts`, `runtime-asset.ts` |
18
+ | Session environment | `session-env.ts` (PI_* contract shared by all kernels; mirrors the core bash tool) |
18
19
 
19
20
  ## CONVENTIONS
20
21
 
@@ -29,6 +30,12 @@ runner/prelude assets).
29
30
  framed subprocesses through `shared/`.
30
31
  - Subprocess retirement/restart, worker recovery, timeout, and interrupt
31
32
  semantics live here, never in the tool layer.
33
+ - Every kernel exposes the active session's `PI_*` environment (`session-env.ts`):
34
+ inherited values are deleted before the session's values are applied, so any
35
+ child spawned from a cell sees the same session environment a bash-tool child
36
+ sees. The JS worker applies it at init (`worker-core.js`; shell capture pins
37
+ the env view under Bun because `delete process.env.X` does not unsetenv),
38
+ and py/rb/jl spawn with it merged into the interpreter environment.
32
39
 
33
40
  ## ANTI-PATTERNS
34
41
 
@@ -1,12 +1,15 @@
1
1
  import { join } from "node:path";
2
2
  import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
3
- import { type CodemodeRuntimeAssetEnvironment, resolveCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
3
+ import type { SessionEnvironment } from "../session-env.ts";
4
+ import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
4
5
  import { SubprocessKernel, type SubprocessSpawn } from "../shared/subprocess-kernel.ts";
5
6
 
6
7
  export interface JuliaKernelStartOptions {
7
8
  readonly cwd: string;
8
9
  readonly sessionId: string;
9
10
  readonly connection: BridgeConnectionConfig;
11
+ /** Per-session PI_* values merged into the interpreter environment at spawn. */
12
+ readonly sessionEnv?: SessionEnvironment;
10
13
  readonly command?: string;
11
14
  readonly spawn?: SubprocessSpawn;
12
15
  readonly onMessage?: (message: KernelToHostMessage) => void;
@@ -17,7 +20,7 @@ export interface JuliaRunnerPathOptions extends CodemodeRuntimeAssetEnvironment
17
20
  }
18
21
 
19
22
  export function resolveJuliaRunnerPath(options: JuliaRunnerPathOptions = {}): string {
20
- return resolveCodemodeRuntimeAsset(
23
+ return requireCodemodeRuntimeAsset(
21
24
  options.localPath ?? join(import.meta.dirname, "runner.jl"),
22
25
  join("kernels", "jl", "runner.jl"),
23
26
  options,
@@ -42,6 +45,7 @@ export class JuliaKernel extends SubprocessKernel {
42
45
  ],
43
46
  cwd: options.cwd,
44
47
  sessionId: options.sessionId,
48
+ sessionEnv: options.sessionEnv,
45
49
  connection: options.connection,
46
50
  spawn: options.spawn,
47
51
  onMessage: options.onMessage,
@@ -1,7 +1,7 @@
1
1
  import { dirname, join } from "node:path";
2
2
  import { fileURLToPath, pathToFileURL } from "node:url";
3
3
  import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
4
- import { type CodemodeRuntimeAssetEnvironment, resolveCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
4
+ import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
5
5
  import type { JavaScriptKernelMode } from "./kernel-contract.ts";
6
6
  import { spawnNodeWorker } from "./worker-host.ts";
7
7
 
@@ -12,7 +12,7 @@ export interface JavaScriptInlineWorkerEntryUrlOptions extends CodemodeRuntimeAs
12
12
  export function resolveInlineWorkerEntryUrl(options: JavaScriptInlineWorkerEntryUrlOptions = {}): URL {
13
13
  const localPath = options.localPath ?? join(dirname(fileURLToPath(import.meta.url)), "inline-worker-entry.js");
14
14
  return pathToFileURL(
15
- resolveCodemodeRuntimeAsset(localPath, join("kernels", "js", "inline-worker-entry.js"), options),
15
+ requireCodemodeRuntimeAsset(localPath, join("kernels", "js", "inline-worker-entry.js"), options),
16
16
  );
17
17
  }
18
18
 
@@ -1,4 +1,5 @@
1
1
  import type { KernelToHostMessage } from "../../bridge/protocol.ts";
2
+ import type { SessionEnvironment } from "../session-env.ts";
2
3
 
3
4
  export type ResultMessage = Extract<KernelToHostMessage, { type: "result" }>;
4
5
  export type ToolCallMessage = Extract<KernelToHostMessage, { type: "tool-call" }>;
@@ -11,6 +12,8 @@ export interface JavaScriptKernelOptions {
11
12
  readonly parallelPoolWidth: number;
12
13
  readonly onMessage?: (message: KernelToHostMessage) => void;
13
14
  readonly workerEntryUrl?: URL;
15
+ /** Per-session PI_* values applied to the worker environment before the first cell runs. */
16
+ readonly sessionEnv?: SessionEnvironment;
14
17
  }
15
18
 
16
19
  export interface JavaScriptRunInput {
@@ -3,6 +3,17 @@ import { JsWorkerRuntime } from "./worker-runtime.js";
3
3
  // Mirrors INTERRUPT_ACK_OP in src/bridge/reserved.ts (this worker file cannot import TypeScript).
4
4
  const INTERRUPT_ACK_OP = "interrupt-ack";
5
5
 
6
+ // Mirrors SESSION_ENVIRONMENT_KEYS in src/kernels/session-env.ts (this worker file
7
+ // cannot import TypeScript). Keys the active session does not set must be dropped so a
8
+ // value inherited from the host environment never leaks into a cell or its children.
9
+ const SESSION_ENVIRONMENT_KEYS = [
10
+ "PI_SESSION_ID",
11
+ "PI_SESSION_FILE",
12
+ "PI_PROVIDER",
13
+ "PI_MODEL",
14
+ "PI_REASONING_LEVEL",
15
+ ];
16
+
6
17
  export function createWorkerCore(transport, options) {
7
18
  let runtime = null;
8
19
  let activeCell = null;
@@ -54,6 +65,7 @@ export function createWorkerCore(transport, options) {
54
65
 
55
66
  function onMessage(message) {
56
67
  if (message.type === "init") {
68
+ applySessionEnvironment(message.sessionEnv);
57
69
  runtime = new JsWorkerRuntime({
58
70
  cwd: options.cwd,
59
71
  parallelPoolWidth: options.parallelPoolWidth,
@@ -98,6 +110,23 @@ function durationMs(startedAtMs) {
98
110
  return Math.max(0, Math.round(performance.now() - startedAtMs));
99
111
  }
100
112
 
113
+ function applySessionEnvironment(sessionEnv) {
114
+ const provided = new Set(Object.keys(sessionEnv ?? {}));
115
+ const deleted = [];
116
+ for (const key of SESSION_ENVIRONMENT_KEYS) {
117
+ if (key in process.env && !provided.has(key)) deleted.push(key);
118
+ delete process.env[key];
119
+ }
120
+ const applied = Object.entries(sessionEnv ?? {});
121
+ for (const [key, value] of applied) process.env[key] = value;
122
+ // A worker's process.env is its own view: Bun.$ and node:child_process read it, but Bun.spawn
123
+ // without an explicit env inherits the OS environ, which also still holds deleted keys because
124
+ // `delete process.env.X` does not unsetenv under Bun. installShellCapture reads these flags and
125
+ // pins the worker's environment view for such children (see worker-shell-capture.js).
126
+ globalThis.__senpi_session_env_deletions__ = deleted;
127
+ globalThis.__senpi_session_env_applied__ = applied.length > 0 || deleted.length > 0;
128
+ }
129
+
101
130
  function valueRepr(value) {
102
131
  if (value === undefined) return undefined;
103
132
  return JSON.stringify(value);
@@ -11,7 +11,7 @@ export async function awaitMaybePromise(value) {
11
11
 
12
12
  export function wrapUserCode(code) {
13
13
  const persistentCode = persistTopLevelDeclarations(code);
14
- if (/\breturn\b/u.test(persistentCode)) return `(async () => {\n${persistentCode}\n})()`;
14
+ if (scanTopLevelStatements(persistentCode).hasTopLevelReturn) return `(async () => {\n${persistentCode}\n})()`;
15
15
  return `(async () => {\n${captureLastExpression(persistentCode)}\n})()`;
16
16
  }
17
17
 
@@ -705,7 +705,7 @@ function skipBlockComment(code, start) {
705
705
  }
706
706
 
707
707
  function captureLastExpression(code) {
708
- const start = findLastTopLevelStatementStart(code);
708
+ const start = scanTopLevelStatements(code).lastStatementStart;
709
709
  const head = code.slice(0, start);
710
710
  const tail = code.slice(start).trim();
711
711
  if (!tail || isStatementOnly(tail)) return code;
@@ -726,7 +726,7 @@ function isStatementOnly(source) {
726
726
 
727
727
  const CONTROL_PAREN_KEYWORDS = new Set(["catch", "for", "if", "switch", "while", "with"]);
728
728
 
729
- function findLastTopLevelStatementStart(code) {
729
+ function scanTopLevelStatements(code) {
730
730
  let start = 0;
731
731
  let round = 0;
732
732
  let square = 0;
@@ -734,6 +734,7 @@ function findLastTopLevelStatementStart(code) {
734
734
  let canStartRegex = true;
735
735
  let lastSignificant = "";
736
736
  let pendingControlParen = false;
737
+ let hasTopLevelReturn = false;
737
738
  const controlParens = [];
738
739
  for (let index = 0; index < code.length; index += 1) {
739
740
  const char = code[index];
@@ -763,8 +764,11 @@ function findLastTopLevelStatementStart(code) {
763
764
  if (isIdentifierStart(char)) {
764
765
  const end = readIdentifier(code, index);
765
766
  const token = code.slice(index, end);
766
- canStartRegex = REGEX_PREFIX_KEYWORDS.has(token);
767
- pendingControlParen = CONTROL_PAREN_KEYWORDS.has(token);
767
+ const isPropertyName = lastSignificant === ".";
768
+ const atTopLevel = round === 0 && square === 0 && curly === 0;
769
+ if (token === "return" && atTopLevel && !isPropertyName) hasTopLevelReturn = true;
770
+ canStartRegex = !isPropertyName && REGEX_PREFIX_KEYWORDS.has(token);
771
+ pendingControlParen = !isPropertyName && CONTROL_PAREN_KEYWORDS.has(token);
768
772
  lastSignificant = code[end - 1];
769
773
  index = end - 1;
770
774
  continue;
@@ -828,7 +832,7 @@ function findLastTopLevelStatementStart(code) {
828
832
  pendingControlParen = false;
829
833
  }
830
834
  }
831
- return start;
835
+ return { lastStatementStart: start, hasTopLevelReturn };
832
836
  }
833
837
 
834
838
  const STATEMENT_CONTINUATION_KEYWORDS = new Set(["catch", "else", "finally"]);
@@ -17,3 +17,19 @@ export interface ShellCaptureOptions {
17
17
  }
18
18
 
19
19
  export function installShellCapture(options: ShellCaptureOptions): ShellCaptureRestore;
20
+
21
+ declare global {
22
+ /**
23
+ * Set by the JS worker core when applying the session environment deleted inherited
24
+ * `PI_*` keys (see worker-core.js). Under Bun a `delete process.env.X` does not
25
+ * unsetenv, so shell capture pins the worker's environment view for spawned children
26
+ * while this list is non-empty.
27
+ */
28
+ var __senpi_session_env_deletions__: string[] | undefined;
29
+ /**
30
+ * Set by the JS worker core once a session environment was applied (values set or inherited
31
+ * keys deleted). Bun.spawn without an explicit env inherits the OS environ rather than the
32
+ * worker's process.env, so shell capture pins the worker's view whenever this is true.
33
+ */
34
+ var __senpi_session_env_applied__: boolean | undefined;
35
+ }
@@ -13,11 +13,23 @@ export function installShellCapture(options) {
13
13
  if (!isBunRuntime(bun)) return () => {};
14
14
  const originalShell = bun.$;
15
15
  const originalSpawn = bun.spawn;
16
+ const originalSpawnSync = typeof bun.spawnSync === "function" ? bun.spawnSync : null;
17
+ const deletedKeys = globalThis.__senpi_session_env_deletions__;
18
+ const pinEnv =
19
+ globalThis.__senpi_session_env_applied__ === true || (Array.isArray(deletedKeys) && deletedKeys.length > 0);
20
+ if (pinEnv && typeof originalShell.env === "function") {
21
+ // Bun.spawn without an explicit env inherits the OS environ, not the worker's process.env,
22
+ // and deleting from process.env does not unsetenv under Bun. Pinning the worker's
23
+ // environment view mirrors the bash tool, which always spawns with an explicit env.
24
+ originalShell.env({ ...process.env });
25
+ }
16
26
  bun.$ = capturedShell(originalShell, options);
17
- bun.spawn = capturedSpawn(originalSpawn, options);
27
+ bun.spawn = capturedSpawn(originalSpawn, options, pinEnv);
28
+ if (originalSpawnSync !== null) bun.spawnSync = capturedSpawnSync(originalSpawnSync, pinEnv);
18
29
  return () => {
19
30
  bun.$ = originalShell;
20
31
  bun.spawn = originalSpawn;
32
+ if (originalSpawnSync !== null) bun.spawnSync = originalSpawnSync;
21
33
  };
22
34
  }
23
35
 
@@ -103,20 +115,44 @@ function outputText(value) {
103
115
  return typeof value === "string" ? value : "";
104
116
  }
105
117
 
106
- function capturedSpawn(originalSpawn, options) {
118
+ // Bun.spawnSync inherits the OS environ the same way Bun.spawn does, so a cell calling it
119
+ // without an explicit env must get the worker's view pinned too (measured on Bun 1.4.0).
120
+ function capturedSpawnSync(originalSpawnSync, pinEnv) {
121
+ return (...args) => {
122
+ if (!pinEnv) return originalSpawnSync(...args);
123
+ const [first, second] = args;
124
+ if (Array.isArray(first)) {
125
+ const spawnOptions = second === undefined ? {} : second;
126
+ if (spawnOptions === null || typeof spawnOptions !== "object" || spawnOptions.env !== undefined)
127
+ return originalSpawnSync(...args);
128
+ return originalSpawnSync(first, { ...spawnOptions, env: { ...process.env } });
129
+ }
130
+ if (first !== null && typeof first === "object" && first.env === undefined)
131
+ return originalSpawnSync({ ...first, env: { ...process.env } });
132
+ return originalSpawnSync(...args);
133
+ };
134
+ }
135
+
136
+ function capturedSpawn(originalSpawn, options, pinEnv) {
107
137
  return (...args) => {
108
138
  if (!options.isActive()) return originalSpawn(...args);
109
139
  const [first, second] = args;
110
140
  let child;
111
141
  if (Array.isArray(first)) {
112
142
  const spawnOptions = second === undefined ? {} : second;
113
- child = needsStderrCapture(spawnOptions)
114
- ? drainStderr(originalSpawn(first, { ...spawnOptions, stderr: "pipe" }), options.emitText)
115
- : originalSpawn(...args);
143
+ const effective = pinEnv && spawnOptions.env === undefined ? { ...spawnOptions, env: { ...process.env } } : spawnOptions;
144
+ child = needsStderrCapture(effective)
145
+ ? drainStderr(originalSpawn(first, { ...effective, stderr: "pipe" }), options.emitText)
146
+ : effective === spawnOptions
147
+ ? originalSpawn(...args)
148
+ : originalSpawn(first, effective);
116
149
  } else {
117
- child = needsStderrCapture(first)
118
- ? drainStderr(originalSpawn({ ...first, stderr: "pipe" }), options.emitText)
119
- : originalSpawn(...args);
150
+ const effective = pinEnv && first !== null && typeof first === "object" && first.env === undefined ? { ...first, env: { ...process.env } } : first;
151
+ child = needsStderrCapture(effective)
152
+ ? drainStderr(originalSpawn({ ...effective, stderr: "pipe" }), options.emitText)
153
+ : effective === first
154
+ ? originalSpawn(...args)
155
+ : originalSpawn(effective);
120
156
  }
121
157
  options.onChild?.(child);
122
158
  return child;
@@ -1,6 +1,6 @@
1
1
  import { dirname, join } from "node:path";
2
2
  import { fileURLToPath, pathToFileURL } from "node:url";
3
- import { type CodemodeRuntimeAssetEnvironment, resolveCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
3
+ import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
4
4
  import { createInlineWorker, type WorkerLike } from "./inline-worker.ts";
5
5
  import { type JavaScriptKernelOptions, localBridgeConnection } from "./local-module-loader.ts";
6
6
  import { spawnNodeWorker, WorkerStartupCancelledError, waitForReady } from "./worker-host.ts";
@@ -11,7 +11,7 @@ export interface JavaScriptWorkerEntryUrlOptions extends CodemodeRuntimeAssetEnv
11
11
 
12
12
  export function resolveJsWorkerEntryUrl(options: JavaScriptWorkerEntryUrlOptions = {}): URL {
13
13
  const localPath = options.localPath ?? join(dirname(fileURLToPath(import.meta.url)), "worker-entry.js");
14
- return pathToFileURL(resolveCodemodeRuntimeAsset(localPath, join("kernels", "js", "worker-entry.js"), options));
14
+ return pathToFileURL(requireCodemodeRuntimeAsset(localPath, join("kernels", "js", "worker-entry.js"), options));
15
15
  }
16
16
 
17
17
  export interface WorkerStartupHooks {
@@ -64,6 +64,7 @@ async function initializeWorker(
64
64
  type: "init",
65
65
  sessionId: options.sessionId,
66
66
  connection: localBridgeConnection(options),
67
+ ...(options.sessionEnv === undefined ? {} : { sessionEnv: options.sessionEnv }),
67
68
  });
68
69
  await ready;
69
70
  }
@@ -1,4 +1,5 @@
1
1
  import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
2
+ import type { SessionEnvironment } from "../session-env.ts";
2
3
  import type { KernelSpawnProcess } from "./process.ts";
3
4
  import type { PythonTransportResult } from "./transport.ts";
4
5
 
@@ -8,6 +9,8 @@ export interface PythonKernelStartOptions {
8
9
  readonly cwd: string;
9
10
  readonly connection: BridgeConnectionConfig;
10
11
  readonly env?: NodeJS.ProcessEnv;
12
+ /** Per-session PI_* values merged into the interpreter environment at spawn. */
13
+ readonly sessionEnv?: SessionEnvironment;
11
14
  readonly startupTimeoutMs?: number;
12
15
  readonly onMessage?: (message: KernelToHostMessage) => void;
13
16
  readonly spawnProcess?: KernelSpawnProcess;
@@ -8,7 +8,8 @@ import {
8
8
  isKernelToHostMessage,
9
9
  type KernelToHostMessage,
10
10
  } from "../../bridge/protocol.ts";
11
- import { type CodemodeRuntimeAssetEnvironment, resolveCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
11
+ import { applySessionEnvironment, type SessionEnvironment } from "../session-env.ts";
12
+ import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
12
13
  import {
13
14
  defaultSpawn,
14
15
  hardKill,
@@ -36,6 +37,8 @@ export interface PythonTransportOptions {
36
37
  readonly cwd: string;
37
38
  readonly connection: BridgeConnectionConfig;
38
39
  readonly env?: NodeJS.ProcessEnv;
40
+ /** Per-session PI_* values merged into the interpreter environment at spawn. */
41
+ readonly sessionEnv?: SessionEnvironment;
39
42
  readonly startupTimeoutMs: number;
40
43
  readonly onMessage?: (message: KernelToHostMessage) => void;
41
44
  readonly spawnProcess?: KernelSpawnProcess;
@@ -53,7 +56,7 @@ export interface PythonPreludePathOptions extends CodemodeRuntimeAssetEnvironmen
53
56
  }
54
57
 
55
58
  export function resolvePythonPreludePath(options: PythonPreludePathOptions = {}): string {
56
- return resolveCodemodeRuntimeAsset(
59
+ return requireCodemodeRuntimeAsset(
57
60
  options.localPath ?? join(dirname(fileURLToPath(import.meta.url)), "prelude.py"),
58
61
  join("kernels", "py", "prelude.py"),
59
62
  options,
@@ -83,7 +86,12 @@ export class PythonKernelTransport {
83
86
  command: invocation.command,
84
87
  args: [...invocation.args, "-u", scriptPath],
85
88
  cwd: options.cwd,
86
- env: { ...process.env, ...options.env, PYTHONUNBUFFERED: "1", PYTHONIOENCODING: "utf-8" },
89
+ env: {
90
+ ...applySessionEnvironment(process.env, options.sessionEnv),
91
+ ...options.env,
92
+ PYTHONUNBUFFERED: "1",
93
+ PYTHONIOENCODING: "utf-8",
94
+ },
87
95
  };
88
96
  const child = (options.spawnProcess ?? defaultSpawn)(spawnOptions);
89
97
  const transport = new PythonKernelTransport(options, child);
@@ -1,12 +1,15 @@
1
1
  import { join } from "node:path";
2
2
  import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
3
- import { type CodemodeRuntimeAssetEnvironment, resolveCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
3
+ import type { SessionEnvironment } from "../session-env.ts";
4
+ import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
4
5
  import { SubprocessKernel, type SubprocessSpawn } from "../shared/subprocess-kernel.ts";
5
6
 
6
7
  export interface RubyKernelStartOptions {
7
8
  readonly cwd: string;
8
9
  readonly sessionId: string;
9
10
  readonly connection: BridgeConnectionConfig;
11
+ /** Per-session PI_* values merged into the interpreter environment at spawn. */
12
+ readonly sessionEnv?: SessionEnvironment;
10
13
  readonly command?: string;
11
14
  readonly spawn?: SubprocessSpawn;
12
15
  readonly onMessage?: (message: KernelToHostMessage) => void;
@@ -17,7 +20,7 @@ export interface RubyRunnerPathOptions extends CodemodeRuntimeAssetEnvironment {
17
20
  }
18
21
 
19
22
  export function resolveRubyRunnerPath(options: RubyRunnerPathOptions = {}): string {
20
- return resolveCodemodeRuntimeAsset(
23
+ return requireCodemodeRuntimeAsset(
21
24
  options.localPath ?? join(import.meta.dirname, "runner.rb"),
22
25
  join("kernels", "rb", "runner.rb"),
23
26
  options,
@@ -31,6 +34,7 @@ export class RubyKernel extends SubprocessKernel {
31
34
  args: [resolveRubyRunnerPath()],
32
35
  cwd: options.cwd,
33
36
  sessionId: options.sessionId,
37
+ sessionEnv: options.sessionEnv,
34
38
  connection: options.connection,
35
39
  spawn: options.spawn,
36
40
  onMessage: options.onMessage,
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Session environment exposed to eval kernels and every child they spawn.
3
+ *
4
+ * Mirrors the shell-tool session environment contract in the senpi core
5
+ * (`resolveSpawnContext` in `packages/coding-agent/src/core/tools/bash.ts` and
6
+ * `docs/environment-variables.md`): the per-session `PI_*` variables are
7
+ * deleted from the inherited environment first, then set from the active
8
+ * session, so a stale inherited value never leaks into a kernel child. A child
9
+ * spawned from an eval cell must see the same session environment a child
10
+ * spawned from the bash tool sees.
11
+ */
12
+ export const SESSION_ENVIRONMENT_KEYS = [
13
+ "PI_SESSION_ID",
14
+ "PI_SESSION_FILE",
15
+ "PI_PROVIDER",
16
+ "PI_MODEL",
17
+ "PI_REASONING_LEVEL",
18
+ ] as const;
19
+
20
+ /** Resolved per-session values for {@link SESSION_ENVIRONMENT_KEYS}; absent keys stay unset. */
21
+ export type SessionEnvironment = Readonly<Record<string, string>>;
22
+
23
+ /** Structural slice of `ExtensionContext` the session environment is resolved from. */
24
+ export interface SessionEnvironmentSource {
25
+ readonly sessionManager: {
26
+ getSessionId(): string;
27
+ getSessionFile(): string | undefined;
28
+ };
29
+ readonly model?: { readonly provider: string; readonly id: string } | undefined;
30
+ readonly thinkingLevel?: string | undefined;
31
+ }
32
+
33
+ export function sessionEnvironmentFrom(source: SessionEnvironmentSource): SessionEnvironment {
34
+ const env: Record<string, string> = {};
35
+ env.PI_SESSION_ID = source.sessionManager.getSessionId();
36
+ const sessionFile = source.sessionManager.getSessionFile();
37
+ if (sessionFile) env.PI_SESSION_FILE = sessionFile;
38
+ const model = source.model;
39
+ if (model) {
40
+ env.PI_PROVIDER = model.provider;
41
+ env.PI_MODEL = model.id;
42
+ }
43
+ if (source.thinkingLevel) env.PI_REASONING_LEVEL = source.thinkingLevel;
44
+ return env;
45
+ }
46
+
47
+ /**
48
+ * Merges a session environment over a base environment the way the bash tool
49
+ * does: every {@link SESSION_ENVIRONMENT_KEYS} entry is dropped from `base`
50
+ * first, then the provided session values are applied.
51
+ */
52
+ export function applySessionEnvironment(base: NodeJS.ProcessEnv, sessionEnv?: SessionEnvironment): NodeJS.ProcessEnv {
53
+ const merged: NodeJS.ProcessEnv = { ...base };
54
+ for (const key of SESSION_ENVIRONMENT_KEYS) delete merged[key];
55
+ return { ...merged, ...sessionEnv };
56
+ }
@@ -6,26 +6,52 @@ export interface CodemodeRuntimeAssetEnvironment {
6
6
  readonly executablePath?: string;
7
7
  }
8
8
 
9
+ export class CodemodeRuntimeAssetMissingError extends Error {
10
+ readonly packageRelativePath: string;
11
+ readonly localPath: string;
12
+ readonly sidecarPath: string;
13
+ readonly executablePath: string;
14
+
15
+ constructor(packageRelativePath: string, localPath: string, sidecarPath: string, executablePath: string) {
16
+ super(
17
+ `codemode runtime asset ${packageRelativePath} is unavailable: module-relative path ${localPath} is not readable and the codemode sidecar is missing beside the executable ${executablePath} (expected ${sidecarPath}). Ship node_modules/@code-yeongyu/senpi-codemode next to the executable.`,
18
+ );
19
+ this.name = "CodemodeRuntimeAssetMissingError";
20
+ this.packageRelativePath = packageRelativePath;
21
+ this.localPath = localPath;
22
+ this.sidecarPath = sidecarPath;
23
+ this.executablePath = executablePath;
24
+ }
25
+ }
26
+
27
+ export function isBunVirtualPath(path: string): boolean {
28
+ return path.includes("/$bunfs/") || path.includes("~BUN") || path.includes("%7EBUN");
29
+ }
30
+
31
+ function sidecarPathFor(packageRelativePath: string, executablePath: string): string {
32
+ return join(dirname(executablePath), "node_modules", "@code-yeongyu", "senpi-codemode", "src", packageRelativePath);
33
+ }
34
+
35
+ export function requireCodemodeRuntimeAsset(
36
+ localPath: string,
37
+ packageRelativePath: string,
38
+ { executablePath = process.execPath }: CodemodeRuntimeAssetEnvironment = {},
39
+ ): string {
40
+ const sidecarPath = sidecarPathFor(packageRelativePath, executablePath);
41
+ if (!isBunVirtualPath(localPath) && existsSync(localPath)) return localPath;
42
+ if (existsSync(sidecarPath)) return sidecarPath;
43
+ throw new CodemodeRuntimeAssetMissingError(packageRelativePath, localPath, sidecarPath, executablePath);
44
+ }
45
+
9
46
  export function resolveCodemodeRuntimeAsset(
10
47
  localPath: string,
11
48
  packageRelativePath: string,
12
49
  { bunVersion = process.versions.bun, executablePath = process.execPath }: CodemodeRuntimeAssetEnvironment = {},
13
50
  ): string {
14
- if (existsSync(localPath)) {
15
- return localPath;
16
- }
51
+ if (existsSync(localPath)) return localPath;
17
52
  if (bunVersion) {
18
- const sidecarPath = join(
19
- dirname(executablePath),
20
- "node_modules",
21
- "@code-yeongyu",
22
- "senpi-codemode",
23
- "src",
24
- packageRelativePath,
25
- );
26
- if (existsSync(sidecarPath)) {
27
- return sidecarPath;
28
- }
53
+ const sidecarPath = sidecarPathFor(packageRelativePath, executablePath);
54
+ if (existsSync(sidecarPath)) return sidecarPath;
29
55
  }
30
56
  return localPath;
31
57
  }
@@ -1,4 +1,5 @@
1
1
  import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
2
+ import type { SessionEnvironment } from "../session-env.ts";
2
3
  import type { SubprocessSpawn } from "./subprocess-process.ts";
3
4
 
4
5
  export interface KernelRunInput {
@@ -15,6 +16,8 @@ export interface SubprocessKernelOptions {
15
16
  readonly args: readonly string[];
16
17
  readonly cwd?: string;
17
18
  readonly env?: NodeJS.ProcessEnv;
19
+ /** Per-session PI_* values merged into the interpreter environment at spawn. */
20
+ readonly sessionEnv?: SessionEnvironment;
18
21
  readonly sessionId: string;
19
22
  readonly connection: BridgeConnectionConfig;
20
23
  readonly spawn?: SubprocessSpawn;
@@ -1,6 +1,7 @@
1
1
  import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
2
2
  import { decodeBridgeFrame, encodeBridgeFrame, isKernelToHostMessage } from "../../bridge/protocol.ts";
3
3
  import type { KernelInterruptHandle } from "../../tool/types.ts";
4
+ import { applySessionEnvironment } from "../session-env.ts";
4
5
  import type { KernelResult, KernelRunInput, SubprocessKernelOptions, ToolCallMessage } from "./subprocess-contract.ts";
5
6
  import { type SubprocessLike, SubprocessProcess, type SubprocessSpawn, spawnSubprocess } from "./subprocess-process.ts";
6
7
  import { SubprocessRunQueue } from "./subprocess-queue.ts";
@@ -133,7 +134,14 @@ export class SubprocessKernel {
133
134
  }
134
135
 
135
136
  private spawnProcess(): void {
136
- const child = spawnSubprocess(this.options.spawn, this.options);
137
+ const child = spawnSubprocess(this.options.spawn, {
138
+ ...this.options,
139
+ env:
140
+ this.options.env ??
141
+ (this.options.sessionEnv
142
+ ? applySessionEnvironment(globalThis.process.env, this.options.sessionEnv)
143
+ : undefined),
144
+ });
137
145
  const process = new SubprocessProcess(child, {
138
146
  onLine: (source, line) => this.handleLine(source, line),
139
147
  onStderr: (source, data) => this.handleMessage(source, { type: "text", stream: "stderr", data }),
@@ -28,12 +28,14 @@ export function resolveSessionArtifactsDir(sessionFile: string | undefined): Ses
28
28
 
29
29
  export interface TruncationMeta {
30
30
  readonly direction: "head" | "tail" | "middle";
31
- readonly truncatedBy: "lines" | "bytes" | "middle";
31
+ readonly truncatedBy: "lines" | "bytes" | "columns" | "middle";
32
32
  readonly totalLines: number;
33
33
  readonly totalBytes: number;
34
34
  readonly outputLines: number;
35
35
  readonly outputBytes: number;
36
36
  readonly maxBytes?: number;
37
+ readonly maxColumns?: number;
38
+ readonly columnTruncatedLines?: number;
37
39
  readonly shownRange?: { readonly start: number; readonly end: number };
38
40
  readonly headRange?: { readonly start: number; readonly end: number };
39
41
  readonly tailRange?: { readonly start: number; readonly end: number };
@@ -72,7 +74,7 @@ export function formatTruncationWarning(meta: TruncationMeta | undefined): strin
72
74
  meta.shownRange !== undefined && meta.shownRange.end >= meta.shownRange.start
73
75
  ? `Showing lines ${meta.shownRange.start}-${meta.shownRange.end} of ${meta.totalLines}`
74
76
  : `Showing ${meta.outputLines} of ${meta.totalLines} lines`;
75
- if (meta.truncatedBy === "bytes") message += ` (${formatBytes(meta.maxBytes ?? meta.outputBytes)} limit)`;
77
+ message += formatByteLoss(meta);
76
78
  break;
77
79
  default:
78
80
  return assertNever(meta.direction);
@@ -81,6 +83,28 @@ export function formatTruncationWarning(meta: TruncationMeta | undefined): strin
81
83
  return `[${message}]`;
82
84
  }
83
85
 
86
+ function formatByteLoss(meta: TruncationMeta): string {
87
+ const dropped = formatBytes(Math.max(0, meta.totalBytes - meta.outputBytes));
88
+ switch (meta.truncatedBy) {
89
+ case "columns": {
90
+ const clamped = meta.columnTruncatedLines ?? 0;
91
+ const width = meta.maxColumns === undefined ? "the column cap" : `${meta.maxColumns} columns`;
92
+ return `; ${clamped} line${clamped === 1 ? "" : "s"} clamped to ${width} (${dropped} dropped)`;
93
+ }
94
+ case "bytes":
95
+ return meta.maxBytes === undefined ? ` (${dropped} dropped)` : ` (${formatBytes(meta.maxBytes)} limit)`;
96
+ case "lines":
97
+ case "middle":
98
+ return "";
99
+ default:
100
+ return assertNeverCause(meta.truncatedBy);
101
+ }
102
+ }
103
+
104
+ function assertNeverCause(value: never): never {
105
+ throw new TypeError(`Unhandled truncation cause: ${String(value)}`);
106
+ }
107
+
84
108
  export function stripOutputNotice(text: string, meta: TruncationMeta | undefined): string {
85
109
  const notice = formatTruncationWarning(meta);
86
110
  if (notice === null) return text;
package/src/tool/image.ts CHANGED
@@ -119,7 +119,7 @@ export class EvalOutputCollector {
119
119
  async finish(): Promise<EvalOutputResult> {
120
120
  await this.#processImages();
121
121
  const summary = await this.#finalSummary();
122
- const meta = truncationMetaFromSummary(summary);
122
+ const meta = truncationMetaFromSummary(summary, this.#options.maxColumns);
123
123
  const notice = summary.artifactId === undefined ? undefined : artifactNotice(summary.artifactId);
124
124
  return {
125
125
  output: summary.output.trimEnd(),
@@ -215,7 +215,7 @@ function formatDisplayJson(value: unknown): string {
215
215
  return `${text.slice(0, MAX_DISPLAY_TEXT_BYTES)}\n[…${text.length - MAX_DISPLAY_TEXT_BYTES}ch elided…]`;
216
216
  }
217
217
 
218
- function truncationMetaFromSummary(summary: OutputSummary): TruncationMeta | undefined {
218
+ function truncationMetaFromSummary(summary: OutputSummary, maxColumns: number): TruncationMeta | undefined {
219
219
  if (!summary.truncated) return undefined;
220
220
  const artifact = summary.artifactId === undefined ? {} : { artifactId: summary.artifactId };
221
221
  if (summary.elidedBytes !== undefined && summary.elidedBytes > 0) {
@@ -239,9 +239,18 @@ function truncationMetaFromSummary(summary: OutputSummary): TruncationMeta | und
239
239
  ...artifact,
240
240
  };
241
241
  }
242
+ const droppedBytes = Math.max(0, summary.totalBytes - summary.outputBytes);
243
+ const clampedLines = summary.columnTruncatedLines ?? 0;
244
+ const columnOnly = clampedLines > 0 && (summary.columnDroppedBytes ?? 0) >= droppedBytes;
245
+ const byteCapped = summary.totalBytes - (summary.columnDroppedBytes ?? 0) > DEFAULT_MAX_BYTES;
242
246
  return {
243
247
  direction: "tail",
244
- truncatedBy: summary.outputBytes < summary.totalBytes ? "bytes" : "lines",
248
+ truncatedBy: columnOnly ? "columns" : byteCapped ? "bytes" : "lines",
249
+ ...(columnOnly
250
+ ? { maxColumns, columnTruncatedLines: clampedLines }
251
+ : byteCapped
252
+ ? { maxBytes: DEFAULT_MAX_BYTES }
253
+ : {}),
245
254
  totalLines: summary.totalLines,
246
255
  totalBytes: summary.totalBytes,
247
256
  outputLines: summary.outputLines,