@code-yeongyu/senpi-codemode 2026.9.7 → 2026.9.9-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,61 @@
12
12
 
13
13
  ### Removed
14
14
 
15
+ ## [2026.9.9-2] - 2026-09-09
16
+
17
+ ### Breaking Changes
18
+
19
+ ### Added
20
+
21
+ ### Changed
22
+
23
+ ### Fixed
24
+
25
+ ### Removed
26
+
27
+ ## [2026.9.9] - 2026-09-09
28
+
29
+ ### Breaking Changes
30
+
31
+ ### Added
32
+
33
+ ### Changed
34
+
35
+ ### Fixed
36
+
37
+ ### Removed
38
+
39
+ ## [2026.9.8] - 2026-09-08
40
+
41
+ ### Breaking Changes
42
+
43
+ ### Added
44
+
45
+ ### Changed
46
+
47
+ - The eval tool description teaches cell mechanics only (batch independent calls, real code, failures kept verbatim, truncated output re-read) and drops the "default execution surface / never a chain / distilled facts only" wording; routing lives in the model's prompt preset.
48
+
49
+ ### Fixed
50
+
51
+ ### Removed
52
+
53
+ ## [2026.9.7-2] - 2026-09-07
54
+
55
+ ### Breaking Changes
56
+
57
+ ### Added
58
+
59
+ ### Changed
60
+
61
+ ### Fixed
62
+
63
+ - 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.
64
+ - 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.
65
+ - 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).
66
+ - 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.
67
+
68
+ ### Removed
69
+
15
70
  ## [2026.9.7] - 2026-09-07
16
71
 
17
72
  ### 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.7",
3
+ "version": "2026.9.9-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.7",
33
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.9.9-2",
34
34
  "typebox": "1.3.18"
35
35
  },
36
36
  "peerDependencies": {
37
- "@code-yeongyu/senpi": "2026.9.7"
37
+ "@code-yeongyu/senpi": "2026.9.9-2"
38
38
  },
39
39
  "devDependencies": {
40
- "@code-yeongyu/senpi": "2026.9.7"
40
+ "@code-yeongyu/senpi": "2026.9.9-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,5 +1,6 @@
1
1
  import { join } from "node:path";
2
2
  import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
3
+ import type { SessionEnvironment } from "../session-env.ts";
3
4
  import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
4
5
  import { SubprocessKernel, type SubprocessSpawn } from "../shared/subprocess-kernel.ts";
5
6
 
@@ -7,6 +8,8 @@ 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;
@@ -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,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;
@@ -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,6 +8,7 @@ import {
8
8
  isKernelToHostMessage,
9
9
  type KernelToHostMessage,
10
10
  } from "../../bridge/protocol.ts";
11
+ import { applySessionEnvironment, type SessionEnvironment } from "../session-env.ts";
11
12
  import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
12
13
  import {
13
14
  defaultSpawn,
@@ -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;
@@ -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,5 +1,6 @@
1
1
  import { join } from "node:path";
2
2
  import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
3
+ import type { SessionEnvironment } from "../session-env.ts";
3
4
  import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
4
5
  import { SubprocessKernel, type SubprocessSpawn } from "../shared/subprocess-kernel.ts";
5
6
 
@@ -7,6 +8,8 @@ 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;
@@ -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
+ }
@@ -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;
@@ -70,29 +70,22 @@ const EVAL_PROMPT_TEMPLATE = `Run one step of code in a persistent kernel.
70
70
  **One eval call = one cell = one logical step.** Top-level names persist per language across eval calls{{#if spawns}}, tool calls and \`task\` subagents{{else}} and tool calls{{/if}}: define helpers and clients once and reuse them instead of re-importing or re-reading. Rebuild state only after \`reset\`, a kernel restart, or a \`NameError\`/\`ReferenceError\`, and check a sentinel variable first so a re-run cannot duplicate side effects.
71
71
 
72
72
  {{#if styleClaude}}<eval_first_batching>
73
- \`eval\` is your default execution surface: if a step needs more than one tool call, write ONE cell that performs the whole step never issue the calls one at a time.
74
- - Enumerate every lookup the step needs, then run all independent ones simultaneously with \`parallel(thunks)\` inside the cell; keep calls sequential only when one result feeds the next.
75
- - Write real code around the calls: loop or comprehend over file sets with \`read()\`/stdlib, branch per case, and wrap risky calls in try/except so one failure degrades only its item — recover or retry inside the cell, keep the batch alive.
76
- - Post-process \`tool.<name>()\` results programmatically — filter, join, aggregate — and return distilled facts, not raw dumps.
73
+ Batch a step's independent calls in one cell with \`parallel(thunks)\`; write real code around them - loops, branches, joins, a try/except per risky item - and keep every failed or missing item in the result verbatim; re-read truncated output before deciding.
77
74
  {{#if monitor}}- Start long-running work (build, test run, deploy, or watch) through \`tool.monitor({ command, filter })\`, putting the decisive-line filter inside the same cell, then keep working until its event wakes the turn.{{/if}}
78
75
  </eval_first_batching>{{/if}}{{#if styleGpt}}<gpt_eval_dialect>
79
- GPT eval: compose multi-tool work inside one cell with \`tool.<name>(args)\` and \`parallel(thunks)\`; do not split a planned step into serial tool calls.
76
+ GPT eval: batch a step's independent tool calls in one cell with \`tool.<name>(args)\` and \`parallel(thunks)\` and inspect every result.
80
77
  {{#if monitor}}- A wait or a long run (build, test run, deploy, watch) starts through \`tool.monitor({ command, filter })\` in that same cell with the decisive-line filter; its event wakes the turn, so no cell sits on the wait and no child is spawned for it.
81
78
  {{/if}}- Long cells detach on timeout and notify on completion; do not poll or re-run them.
82
- - Filter, join, and aggregate tool results in the cell; return only decision-relevant facts.
83
- </gpt_eval_dialect>{{/if}}{{#if styleCodex}}Route multi-call steps through eval: one cell per step, independent lookups dispatched together via \`parallel(thunks)\`; keep work sequential only when one result determines the next action.
84
- - Loop or comprehend over file sets with \`read()\`/stdlib instead of reading files one call at a time; post-process \`tool.<name>()\` results programmatically — filter, join, aggregate.
85
- - Wrap failable calls in try/except inside the cell; a failed item degrades only itself. After two distinct failed strategies for the same fact, fall back to direct tool calls.
86
- - Reduce large results in-kernel to the facts the task needs before returning.
87
- {{#if monitor}}- Long-running build/test/deploy/watch work: start \`tool.monitor({ command, filter })\` with the decisive-line filter inside the same cell, then continue working until its event wakes the turn.{{/if}}{{/if}}{{#if styleKimi}}**EVAL IS YOUR SUPERPOWER — MAKE IT YOUR DEFAULT WAY TO ACT.** Before any step, think: "how do I execute this WHOLE step in ONE parallelized cell?" then write that ONE cell.
88
- - **BATCH EVERYTHING AT ONCE:** enumerate EVERY independent lookup the step needs and dispatch them ALL simultaneously with \`parallel(thunks)\` in that cell; keep calls sequential only when one result feeds the next.
89
- - **WRITE REAL CODE, NOT CALL CHAINS:** loop or comprehend over file sets with \`read()\`/stdlib, post-process \`tool.<name>()\` results programmatically, and put try/except around each risky call so the rest of the batch completes.
90
- - **DISTILL IN-KERNEL:** filter, join, and aggregate \`tool.<name>()\` results in code, then return ONLY the distilled facts.
91
- {{#if monitor}}- **DO start long-running build, test run, deploy, or watch work with \`tool.monitor({ command, filter })\`, put the decisive-line filter INSIDE THE SAME CELL, and KEEP WORKING until its event wakes the turn.**{{/if}}{{/if}}{{#if styleDefault}}**EVAL IS YOUR PRIMARY EXECUTION SURFACE.** Any step that needs MORE THAN ONE tool call MUST be written as ONE cell — NEVER as a chain of single tool calls.
92
- - **PLAN THE WHOLE STEP, THEN BATCH IT.** Enumerate every read/search/lookup the step needs and dispatch ALL independent ones through \`parallel(thunks)\` in one cell.
93
- - **WRITE REAL CODE, NOT CALL LISTS.** Loop or comprehend over file sets with \`read()\`/stdlib, branch \`if\`/\`else\` per case, post-process \`tool.<name>()\` results programmatically, and wrap EVERY risky call in try/except so ONE failure NEVER kills the batch.
94
- - **DISTILL IN-KERNEL.** Filter, join, diff, and aggregate in code before returning; return facts, NOT dumps.
95
- {{#if monitor}}- **LONG-RUNNING build, test run, deploy, or watch work MUST start with \`tool.monitor({ command, filter })\`, with the decisive-line filter INSIDE THE SAME CELL; KEEP WORKING until its event wakes the turn.**{{/if}}{{/if}}
79
+ - Keep every failed or missing item in the result verbatim and re-read truncated output before deciding.
80
+ </gpt_eval_dialect>{{/if}}{{#if styleCodex}}Route a step's independent lookups through one eval cell via \`parallel(thunks)\` and inspect every result.
81
+ - Loop or comprehend over file sets with \`read()\`/stdlib instead of reading files one call at a time; post-process \`tool.<name>()\` results programmatically.
82
+ - Wrap failable calls in try/except inside the cell and keep every failed item in the result verbatim; after two distinct failed strategies for the same fact, fall back to direct tool calls.
83
+ - Re-read truncated output before deciding on it.
84
+ {{#if monitor}}- Long-running build/test/deploy/watch work: start \`tool.monitor({ command, filter })\` with the decisive-line filter inside the same cell, then continue working until its event wakes the turn.{{/if}}{{/if}}{{#if styleKimi}}Put a step's independent calls into one cell with \`parallel(thunks)\`.
85
+ - Write real code around the calls - loops, joins, a try/except per risky item - and keep every failed or missing item in the result verbatim; re-read truncated output before deciding.
86
+ {{#if monitor}}- Start long-running build, test run, deploy, or watch work with \`tool.monitor({ command, filter })\`, put the decisive-line filter inside the same cell, and keep working until its event wakes the turn.{{/if}}{{/if}}{{#if styleDefault}}Batch a step's independent calls in one cell with \`parallel(thunks)\`.
87
+ - Write real code around the calls - loops, branches, joins, a try/except per risky item - and keep every failed or missing item in the result verbatim; re-read truncated output before deciding.
88
+ {{#if monitor}}- Long-running build, test run, deploy, or watch work starts with \`tool.monitor({ command, filter })\`, with the decisive-line filter inside the same cell; keep working until its event wakes the turn.{{/if}}{{/if}}
96
89
  {{#if hostLine}}
97
90
  Host: {{hostLine}} — cells execute here. Size \`parallel(thunks)\` pools to its cores; \`tool.<name>()\` shell commands must fit this platform, even when the code you are writing targets another machine.
98
91
  {{/if}}
@@ -200,12 +193,12 @@ const GPT_MONITOR_BATCHING_GUIDELINE =
200
193
 
201
194
  const BATCHING_GUIDELINES: Record<EvalEmphasisStyle, string> = {
202
195
  default:
203
- "**EVAL FIRST.** Any step needing MORE THAN ONE tool call MUST be ONE eval cell: run independent calls in parallel, wrap risky calls in try/except, and return distilled facts NEVER a chain of single tool calls.",
196
+ "Prefer eval when a step's calls are independent: one cell runs them together and keeps every failure in its result; edits and result-dependent calls go one at a time, each observed before the next.",
204
197
  claude:
205
- "Prefer eval for any step needing more than one tool call: one cell that runs independent calls in parallel, handles per-call failures in code, and returns distilled facts.",
206
- codex: "Route multi-call steps through eval: one cell per step, independent calls dispatched in parallel; fall back to direct tool calls when one call is sufficient or each result changes the next decision.",
207
- gpt: "Use eval to compose tool work in one cell; long cells detach on timeout and notify on completion, so do not poll.",
208
- kimi: "**EVAL IS YOUR SUPERPOWER DEFAULT TO IT.** Execute EVERY multi-call step as ONE eval cell: run ALL independent calls simultaneously via parallel(thunks), handle failures per item in code, and return ONLY distilled facts.",
198
+ "Prefer eval for a step's independent calls: one cell runs them together and keeps every failure in its result.",
199
+ codex: "Route a step's independent calls through one eval cell and inspect every result; a direct tool call is right when one call is sufficient.",
200
+ gpt: "Use eval to batch a step's independent tool calls in one cell and inspect every result; long cells detach on timeout and notify on completion, so do not poll.",
201
+ kimi: "Put a step's independent calls into one eval cell with parallel(thunks) and keep every failed item in the result.",
209
202
  };
210
203
 
211
204
  function renderTemplate(template: string, context: Context): string {
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,