@code-yeongyu/senpi-codemode 2026.10.5 → 2026.10.6

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,28 @@
12
12
 
13
13
  ### Removed
14
14
 
15
+ ## [2026.10.6] - 2026-10-04
16
+
17
+ ### Breaking Changes
18
+
19
+ ### Added
20
+
21
+ - An opt-in `memory.idleParkMinutes` setting (off by default) closes a kernel that had no cell running or queued for that many minutes to give its memory back; the next cell starts a fresh kernel and its result says every earlier global is lost ([#2452](https://github.com/code-yeongyu/senpi/issues/2452)).
22
+ - Ruby eval results name their largest globals, and Ruby and Julia kernels now get the same large-memory notice as JavaScript and Python when the interpreter footprint crosses `memory.noticeMb` ([#2452](https://github.com/code-yeongyu/senpi/issues/2452)).
23
+ - Julia eval results name their largest globals in that notice ([#2452](https://github.com/code-yeongyu/senpi/issues/2452)).
24
+
25
+ ### Changed
26
+ - Kernel tool descriptors may name any eval language (`js`, `py`, `rb`, `jl`), not only `js`; today only JavaScript kernels define tools, so nothing a session sees changes ([#2452](https://github.com/code-yeongyu/senpi/issues/2452)).
27
+
28
+ ### Fixed
29
+ - A Ruby or Julia kernel whose start hangs no longer leaves its cells waiting forever: startup fails, naming the stage it stalled in, once the runner has printed nothing, changed no stage and its process group has used no CPU for 30 s, so a slow but busy start (a cold Julia compiling its prelude) is never cut off ([#2452](https://github.com/code-yeongyu/senpi/issues/2452)).
30
+ - A `codemode.json` that names a key this version does not know no longer throws away every other setting: the unknown key gets one warning and the rest still apply (known nested objects stay strict). Optional keys for upcoming features (`environments`, `isolation`, `sandbox`, `prompt.advertiseHelpers`, `kernelTools`, `languages.pyInterpreter`) parse with today's behaviour as their defaults ([#2452](https://github.com/code-yeongyu/senpi/issues/2452)).
31
+
32
+ - A Python cell's host calls (`tool.*`, `completion`) no longer go through a configured HTTP proxy: the loopback bridge request ignores proxy settings from the environment and, on Windows, the registry, so a proxy can't refuse a `127.0.0.1` call that never needed it ([#2619](https://github.com/code-yeongyu/senpi/issues/2619)).
33
+ - A JavaScript memory report no longer runs user code: array elements are read through their own descriptors (an index accessor is skipped and the estimate marked approximate), and typed arrays, buffers, Blob, Map and Set are sized through the built-in getters, so a subclass that overrides `byteLength` or `size` is never called ([#2452](https://github.com/code-yeongyu/senpi/issues/2452)).
34
+
35
+ ### Removed
36
+
15
37
  ## [2026.10.5] - 2026-10-03
16
38
 
17
39
  ### Breaking Changes
package/README.md CHANGED
@@ -156,17 +156,31 @@ Configuration is loaded in this order:
156
156
  | `outputSink.maxColumns` | `768` | Maximum columns per printed output line; `0` disables column clamping. A cell's return value is never column-clamped (the byte and line budgets still apply). |
157
157
  | `statusEvents` | `true` | Enables kernel status-event forwarding and rendering. Each cell retains at most 100 status rows; after overflow, one omitted-count row precedes the latest 99 events. |
158
158
  | `memory.gcWatermarkMb` | `256` | JavaScript kernel: a finished cell whose heap reached this size and grew runs a full collection before its result; whenever at least this much stays live, an idle full collection runs about a second after the cell, so dropped globals return their memory without a reset. Python kernel: a finished cell whose process footprint reached this size and grew runs `gc.collect()` (plus glibc `malloc_trim(0)` on Linux) before its result, and while at least this much stays live the next cells collect too (at most 1/20 of the time), so `del rows` returns its memory. `0` disables. Env override: `SENPI_CODEMODE_MEMORY_GC_WATERMARK_MB`. |
159
- | `memory.noticeMb` | `1024` | JavaScript and Python kernels: when live memory after a collection (JS heap, Python process footprint) reaches this size (first time, or 25% more than at the last notice), the result gets one bracketed notice naming the largest globals and how to drop them (`rows = undefined`, `del rows`), plus `details.memory`. Ruby and Julia get no notice. `0` disables. Env override: `SENPI_CODEMODE_MEMORY_NOTICE_MB`. |
159
+ | `memory.noticeMb` | `1024` | When live memory (JS heap or Python footprint after a collection; Ruby and Julia interpreter footprint read by the host after each cell) reaches this size (first time, or 25% more than at the last notice), the result gets one bracketed notice naming the largest globals and how to drop them (`rows = undefined`, `del rows`, `rows = nil`, `rows = nothing`), plus `details.memory`. `0` disables. Env override: `SENPI_CODEMODE_MEMORY_NOTICE_MB`. |
160
160
  | `memory.ceilingMb` | a quarter of physical memory, 2048-8192 | Every kernel (JS heap and Python footprint after a collection; Ruby and Julia interpreter footprint read by the host after each result, without a globals list): when live memory reaches this size, the result says so and the kernel restarts once no cell is running or queued on it; the next result says it was restarted (`details.memory.recycled`). `0` disables. Env override: `SENPI_CODEMODE_MEMORY_CEILING_MB`. Non-zero memory thresholds must satisfy watermark <= notice <= ceiling, otherwise all three use their defaults. |
161
161
  | `memory.retainedResultsMb` | `32` | In-memory byte budget (MiB) for the settled cells kept for `peek`/`list`, on top of the 32-cell count cap; the oldest go first and the newest is always kept. `0` keeps only the count cap. Env override: `SENPI_CODEMODE_RETAINED_RESULTS_MB` (a non-negative integer). |
162
162
  | `memory.retainedImagesMb` | `256` | Disk budget (MiB) for settled-cell images. Images of settled cells (foreground and detached) are written as base64 files under `<session artifacts>/settled-images/` instead of staying in memory, and `peek` reads them back, so it returns the full result. Beyond the budget the oldest files are deleted first; an evicted cell's files are deleted with it; the directory is removed when the session ends. A `peek` whose image file is gone returns the text plus a one-line note. `0` keeps only the count cap. Env override: `SENPI_CODEMODE_RETAINED_IMAGES_MB` (a non-negative integer). |
163
+ | `memory.idleParkMinutes` | `0` | Off by default. When greater than 0, a kernel with no cell running or queued for this many minutes is closed to give its memory back, and the next cell for that language starts a fresh one; that cell's result says the kernel was restarted and every earlier global is lost. Applies to every language. |
164
+ | `languages.pyInterpreter` | unset | Explicit Python interpreter path. Unset keeps today's `PATH` detection. |
165
+ | `environments.managedRoot` | unset | Root directory for managed per-session environments; unset uses the session's default location. |
166
+ | `environments.autoProvision` | `true` | Lets installs provision a managed environment. `false` makes installs refuse with `environment_installer_unavailable`. |
167
+ | `environments.js.installer` | `auto` | Installer for the managed JavaScript environment: `auto`, `bun` or `npm`. |
168
+ | `environments.py.installer` | `pip` | Installer for the managed Python environment. |
169
+ | `isolation.js` | `worker` | JavaScript kernel isolation: `worker` (today's worker thread) or `process`. Env override: `SENPI_CODEMODE_JS_ISOLATION`. |
170
+ | `sandbox.enabled` | `false` | Allows sandbox cells. While `false` the sandbox option is neither accepted nor advertised. |
171
+ | `sandbox.memoryMb` | `64` | Memory cap (MiB) for a sandbox cell. Env override: `SENPI_CODEMODE_SANDBOX_MEMORY_MB` (a positive integer wins). |
172
+ | `sandbox.timeoutSeconds` | `300` | Time limit for a sandbox cell. |
173
+ | `prompt.advertiseHelpers` | `false` | When `true`, one pointer line to `tool_schema('eval:helpers')` is appended to the eval description. |
174
+ | `kernelTools.enabled` | `true` | Allows cells to define kernel tools (`tool(fn)`, `@tool`). `false` makes them refuse with `tools_unavailable`. |
175
+
176
+ The `languages.pyInterpreter`, `environments.*`, `isolation.*`, `sandbox.*`, `prompt.*` and `kernelTools.*` keys are accepted and validated now, with the defaults shown, which match today's behaviour. The effect each of those rows describes takes effect when its feature ships; until then, setting a key changes nothing.
163
177
 
164
178
  `SENPI_CODEMODE_PY`, `SENPI_CODEMODE_JS`, `SENPI_CODEMODE_RB`, and
165
179
  `SENPI_CODEMODE_JL` override the corresponding file setting. `1` or `true`
166
180
  enables; `0` or `false` disables. Any other value leaves the file setting in
167
181
  effect.
168
182
 
169
- Malformed JSON or invalid settings fall back to defaults with a warning.
183
+ A top-level key this version does not know is ignored with one warning naming it, and the rest of the file still applies, so a settings file written for a newer senpi keeps working. Malformed JSON, or an invalid value or an unknown key inside a known setting, falls back to all defaults with a warning.
170
184
  The detached-cell environment override uses the run-budget parser: a positive
171
185
  base-10 integer wins over the file value; zero, negative, and malformed values
172
186
  leave the file value in effect.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-codemode",
3
- "version": "2026.10.5",
3
+ "version": "2026.10.6",
4
4
  "description": "Source-only senpi extension package for codemode evaluation tools",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -32,14 +32,14 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@babel/parser": "8.0.4",
35
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.5",
35
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.6",
36
36
  "typebox": "1.3.34"
37
37
  },
38
38
  "peerDependencies": {
39
- "@code-yeongyu/senpi": "2026.10.5"
39
+ "@code-yeongyu/senpi": "2026.10.6"
40
40
  },
41
41
  "devDependencies": {
42
- "@code-yeongyu/senpi": "2026.10.5"
42
+ "@code-yeongyu/senpi": "2026.10.6"
43
43
  },
44
44
  "keywords": [
45
45
  "senpi",
@@ -29,7 +29,7 @@ export const kernelToolDescriptorSchema = Type.Object({
29
29
  name: Type.String({ minLength: 1 }),
30
30
  description: Type.String(),
31
31
  input_schema: Type.Unknown(),
32
- language: Type.Literal("js"),
32
+ language: Type.Union([Type.Literal("js"), Type.Literal("py"), Type.Literal("rb"), Type.Literal("jl")]),
33
33
  kernel_generation: Type.Integer({ minimum: 0 }),
34
34
  definition_revision: Type.Integer({ minimum: 1 }),
35
35
  });
@@ -0,0 +1,110 @@
1
+ import type { Static } from "typebox";
2
+ import { Type } from "typebox";
3
+
4
+ type Environment = Readonly<Record<string, string | undefined>>;
5
+
6
+ export const JS_ISOLATION_ENVIRONMENT_FLAG = "SENPI_CODEMODE_JS_ISOLATION";
7
+ export const SANDBOX_MEMORY_ENVIRONMENT_FLAG = "SENPI_CODEMODE_SANDBOX_MEMORY_MB";
8
+
9
+ export const featureSettingsProperties = {
10
+ environments: Type.Optional(
11
+ Type.Object(
12
+ {
13
+ managedRoot: Type.Optional(Type.String({ minLength: 1 })),
14
+ autoProvision: Type.Optional(Type.Boolean()),
15
+ js: Type.Optional(
16
+ Type.Object(
17
+ { installer: Type.Union([Type.Literal("auto"), Type.Literal("bun"), Type.Literal("npm")]) },
18
+ { additionalProperties: false },
19
+ ),
20
+ ),
21
+ py: Type.Optional(Type.Object({ installer: Type.Literal("pip") }, { additionalProperties: false })),
22
+ },
23
+ { additionalProperties: false },
24
+ ),
25
+ ),
26
+ isolation: Type.Optional(
27
+ Type.Object(
28
+ { js: Type.Optional(Type.Union([Type.Literal("worker"), Type.Literal("process")])) },
29
+ { additionalProperties: false },
30
+ ),
31
+ ),
32
+ sandbox: Type.Optional(
33
+ Type.Object(
34
+ {
35
+ enabled: Type.Optional(Type.Boolean()),
36
+ memoryMb: Type.Optional(Type.Integer({ minimum: 1 })),
37
+ timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1 })),
38
+ },
39
+ { additionalProperties: false },
40
+ ),
41
+ ),
42
+ prompt: Type.Optional(
43
+ Type.Object({ advertiseHelpers: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
44
+ ),
45
+ kernelTools: Type.Optional(Type.Object({ enabled: Type.Optional(Type.Boolean()) }, { additionalProperties: false })),
46
+ };
47
+
48
+ const featureSettingsSchema = Type.Object(featureSettingsProperties);
49
+
50
+ export type CodemodeFeatureSettings = Static<typeof featureSettingsSchema>;
51
+
52
+ export type JsIsolation = "worker" | "process";
53
+
54
+ export interface ResolvedSandbox {
55
+ readonly enabled: boolean;
56
+ readonly memoryMb: number;
57
+ readonly timeoutSeconds: number;
58
+ }
59
+
60
+ export interface ResolvedEnvironments {
61
+ readonly managedRoot?: string;
62
+ readonly autoProvision: boolean;
63
+ readonly jsInstaller: "auto" | "bun" | "npm";
64
+ readonly pyInstaller: "pip";
65
+ }
66
+
67
+ export function pickFeatureSettings(input: CodemodeFeatureSettings): CodemodeFeatureSettings {
68
+ return {
69
+ ...(input.environments === undefined ? {} : { environments: input.environments }),
70
+ ...(input.isolation === undefined ? {} : { isolation: input.isolation }),
71
+ ...(input.sandbox === undefined ? {} : { sandbox: input.sandbox }),
72
+ ...(input.prompt === undefined ? {} : { prompt: input.prompt }),
73
+ ...(input.kernelTools === undefined ? {} : { kernelTools: input.kernelTools }),
74
+ };
75
+ }
76
+
77
+ /** The environment wins over the file; an unknown value is ignored. Default: today's worker-thread kernel. */
78
+ export function resolveJsIsolation(settings: CodemodeFeatureSettings, env: Environment = process.env): JsIsolation {
79
+ const fromEnv = env[JS_ISOLATION_ENVIRONMENT_FLAG];
80
+ if (fromEnv === "worker" || fromEnv === "process") return fromEnv;
81
+ return settings.isolation?.js ?? "worker";
82
+ }
83
+
84
+ /** Off by default; the environment's memory cap wins over the file when it is a positive integer. */
85
+ export function resolveSandbox(settings: CodemodeFeatureSettings, env: Environment = process.env): ResolvedSandbox {
86
+ const fromEnv = Number.parseInt(env[SANDBOX_MEMORY_ENVIRONMENT_FLAG] ?? "", 10);
87
+ return {
88
+ enabled: settings.sandbox?.enabled ?? false,
89
+ memoryMb: Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : (settings.sandbox?.memoryMb ?? 64),
90
+ timeoutSeconds: settings.sandbox?.timeoutSeconds ?? 300,
91
+ };
92
+ }
93
+
94
+ export function resolveEnvironments(settings: CodemodeFeatureSettings): ResolvedEnvironments {
95
+ const environments = settings.environments;
96
+ return {
97
+ ...(environments?.managedRoot === undefined ? {} : { managedRoot: environments.managedRoot }),
98
+ autoProvision: environments?.autoProvision ?? true,
99
+ jsInstaller: environments?.js?.installer ?? "auto",
100
+ pyInstaller: environments?.py?.installer ?? "pip",
101
+ };
102
+ }
103
+
104
+ export function resolveAdvertiseHelpers(settings: CodemodeFeatureSettings): boolean {
105
+ return settings.prompt?.advertiseHelpers ?? false;
106
+ }
107
+
108
+ export function resolveKernelToolsEnabled(settings: CodemodeFeatureSettings): boolean {
109
+ return settings.kernelTools?.enabled ?? true;
110
+ }
@@ -3,6 +3,9 @@ import { type Static, Type } from "typebox";
3
3
  import type { KernelMemoryThresholds } from "../bridge/memory-protocol.ts";
4
4
  import type { CodemodeSettings, Environment } from "./settings.ts";
5
5
 
6
+ // The longest delay setTimeout honours (2^31 - 1 ms); a longer one fires after 1 ms.
7
+ export const MAX_IDLE_PARK_MINUTES = 35_791;
8
+
6
9
  export const memorySettingsSchema = Type.Object(
7
10
  {
8
11
  gcWatermarkMb: Type.Optional(Type.Number({ minimum: 0 })),
@@ -10,6 +13,7 @@ export const memorySettingsSchema = Type.Object(
10
13
  ceilingMb: Type.Optional(Type.Number({ minimum: 0 })),
11
14
  retainedResultsMb: Type.Optional(Type.Number({ minimum: 0 })),
12
15
  retainedImagesMb: Type.Optional(Type.Number({ minimum: 0 })),
16
+ idleParkMinutes: Type.Optional(Type.Number({ minimum: 0, maximum: MAX_IDLE_PARK_MINUTES })),
13
17
  },
14
18
  { additionalProperties: false },
15
19
  );
@@ -27,6 +31,8 @@ export interface CodemodeMemorySettings {
27
31
  readonly retainedResultsMb: number;
28
32
  /** Disk budget (MiB) for settled-cell images spilled under the session artifacts dir; 0 keeps only the count cap. */
29
33
  readonly retainedImagesMb: number;
34
+ /** Minutes with no cell running or queued before a kernel is closed and restarted on the next cell; absent or 0 never parks. */
35
+ readonly idleParkMinutes?: number;
30
36
  }
31
37
 
32
38
  export const DEFAULT_MEMORY_GC_WATERMARK_MB = 256;
@@ -67,6 +73,7 @@ export function mergeMemorySettings(input: CodemodeMemorySettingsInput | undefin
67
73
  ceilingMb: input?.ceilingMb ?? defaults.ceilingMb,
68
74
  retainedResultsMb: input?.retainedResultsMb ?? defaults.retainedResultsMb,
69
75
  retainedImagesMb: input?.retainedImagesMb ?? defaults.retainedImagesMb,
76
+ ...(input?.idleParkMinutes === undefined ? {} : { idleParkMinutes: input.idleParkMinutes }),
70
77
  };
71
78
  }
72
79
 
@@ -91,6 +98,7 @@ export function validatedMemorySettings(input: CodemodeMemorySettingsInput | und
91
98
  ...defaults,
92
99
  retainedResultsMb: merged.retainedResultsMb,
93
100
  retainedImagesMb: merged.retainedImagesMb,
101
+ ...(merged.idleParkMinutes === undefined ? {} : { idleParkMinutes: merged.idleParkMinutes }),
94
102
  },
95
103
  warnings: [warning],
96
104
  };
@@ -0,0 +1,7 @@
1
+ export const HARD_LIMIT_ENVIRONMENT_FLAG = "SENPI_CODEMODE_HARD_LIMIT_SECONDS";
2
+ export const FOREGROUND_WINDOW_ENVIRONMENT_FLAG = "SENPI_CODEMODE_FOREGROUND_SECONDS";
3
+ export const RUN_BUDGET_ENVIRONMENT_FLAG = "SENPI_CODEMODE_RUN_BUDGET_SECONDS";
4
+ export const MAX_DETACHED_CELLS_ENVIRONMENT_FLAG = "SENPI_CODEMODE_MAX_DETACHED_CELLS";
5
+ export const DEFAULT_MAX_DETACHED_CELLS = 15;
6
+
7
+ export type Environment = Readonly<Record<string, string | undefined>>;
@@ -0,0 +1,72 @@
1
+ import type { CodemodeSettings } from "./settings.ts";
2
+ import {
3
+ DEFAULT_MAX_DETACHED_CELLS,
4
+ type Environment,
5
+ FOREGROUND_WINDOW_ENVIRONMENT_FLAG,
6
+ HARD_LIMIT_ENVIRONMENT_FLAG,
7
+ MAX_DETACHED_CELLS_ENVIRONMENT_FLAG,
8
+ RUN_BUDGET_ENVIRONMENT_FLAG,
9
+ } from "./settings-constants.ts";
10
+
11
+ const languageEnvironmentFlags = {
12
+ py: "SENPI_CODEMODE_PY",
13
+ js: "SENPI_CODEMODE_JS",
14
+ rb: "SENPI_CODEMODE_RB",
15
+ jl: "SENPI_CODEMODE_JL",
16
+ } as const;
17
+
18
+ export function resolveEnabledLanguages(
19
+ settings: CodemodeSettings,
20
+ env: Environment = process.env,
21
+ ): CodemodeSettings["languages"] {
22
+ return {
23
+ py: resolveLanguage(settings.languages.py, env[languageEnvironmentFlags.py]),
24
+ js: resolveLanguage(settings.languages.js, env[languageEnvironmentFlags.js]),
25
+ rb: resolveLanguage(settings.languages.rb, env[languageEnvironmentFlags.rb]),
26
+ jl: resolveLanguage(settings.languages.jl, env[languageEnvironmentFlags.jl]),
27
+ };
28
+ }
29
+
30
+ /** Environment override wins over the settings file; a non-positive or malformed value is ignored. */
31
+ export function resolveHardLimitSeconds(settings: CodemodeSettings, env: Environment = process.env): number {
32
+ return positiveSecondsOverride(env[HARD_LIMIT_ENVIRONMENT_FLAG]) ?? settings.hardLimitSeconds;
33
+ }
34
+
35
+ /** Environment override wins over the settings file; a non-positive or malformed value is ignored. */
36
+ export function resolveForegroundWindowSeconds(settings: CodemodeSettings, env: Environment = process.env): number {
37
+ return positiveSecondsOverride(env[FOREGROUND_WINDOW_ENVIRONMENT_FLAG]) ?? settings.foregroundWindowSeconds;
38
+ }
39
+
40
+ /** Environment override wins over the settings file; a non-positive or malformed value is ignored. */
41
+ export function resolveRunBudgetSeconds(settings: CodemodeSettings, env: Environment = process.env): number {
42
+ return positiveSecondsOverride(env[RUN_BUDGET_ENVIRONMENT_FLAG]) ?? settings.runBudgetSeconds;
43
+ }
44
+
45
+ /** Uses the same positive-integer environment parsing as the run budget. */
46
+ export function resolveMaxDetachedCells(settings: CodemodeSettings, env: Environment = process.env): number {
47
+ return (
48
+ positiveSecondsOverride(env[MAX_DETACHED_CELLS_ENVIRONMENT_FLAG]) ??
49
+ settings.maxDetachedCells ??
50
+ DEFAULT_MAX_DETACHED_CELLS
51
+ );
52
+ }
53
+
54
+ function positiveSecondsOverride(value: string | undefined): number | undefined {
55
+ if (value === undefined) return undefined;
56
+ const parsed = Number.parseInt(value, 10);
57
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
58
+ }
59
+
60
+ function resolveLanguage(fileSetting: boolean, environmentValue: string | undefined): boolean {
61
+ if (environmentValue === undefined) return fileSetting;
62
+ switch (environmentValue.trim().toLowerCase()) {
63
+ case "0":
64
+ case "false":
65
+ return false;
66
+ case "1":
67
+ case "true":
68
+ return true;
69
+ default:
70
+ return fileSetting;
71
+ }
72
+ }
@@ -4,7 +4,10 @@ import { join } from "node:path";
4
4
  import type { Static } from "typebox";
5
5
  import { Type } from "typebox";
6
6
  import { Check } from "typebox/value";
7
+ import { type CodemodeFeatureSettings, featureSettingsProperties, pickFeatureSettings } from "./feature-settings.ts";
7
8
  import { type CodemodeMemorySettings, memorySettingsSchema, validatedMemorySettings } from "./memory-settings.ts";
9
+ import { DEFAULT_MAX_DETACHED_CELLS } from "./settings-constants.ts";
10
+ import { withoutUnknownTopLevelKeys } from "./unknown-settings.ts";
8
11
 
9
12
  export const codemodeSettingsSchema = Type.Object(
10
13
  {
@@ -15,6 +18,7 @@ export const codemodeSettingsSchema = Type.Object(
15
18
  js: Type.Optional(Type.Boolean()),
16
19
  rb: Type.Optional(Type.Boolean()),
17
20
  jl: Type.Optional(Type.Boolean()),
21
+ pyInterpreter: Type.Optional(Type.String({ minLength: 1 })),
18
22
  },
19
23
  { additionalProperties: false },
20
24
  ),
@@ -45,6 +49,7 @@ export const codemodeSettingsSchema = Type.Object(
45
49
  ),
46
50
  statusEvents: Type.Optional(Type.Boolean()),
47
51
  memory: Type.Optional(memorySettingsSchema),
52
+ ...featureSettingsProperties,
48
53
  },
49
54
  { additionalProperties: false },
50
55
  );
@@ -61,12 +66,14 @@ export interface CodemodeOutputSink {
61
66
  readonly maxColumns: number;
62
67
  }
63
68
 
64
- export interface CodemodeSettings {
69
+ export interface CodemodeSettings extends CodemodeFeatureSettings {
65
70
  readonly languages: {
66
71
  readonly py: boolean;
67
72
  readonly js: boolean;
68
73
  readonly rb: boolean;
69
74
  readonly jl: boolean;
75
+ /** Explicit Python interpreter; unset keeps today's PATH detection. */
76
+ readonly pyInterpreter?: string;
70
77
  };
71
78
  /** Idle time an interactive call blocks the turn before the cell detaches; capped by the foreground window. */
72
79
  readonly cellTimeoutSeconds: number;
@@ -118,8 +125,6 @@ export interface LoadedCodemodeSettings {
118
125
  */
119
126
  export const DEFAULT_HARD_LIMIT_SECONDS = 1800;
120
127
 
121
- export const HARD_LIMIT_ENVIRONMENT_FLAG = "SENPI_CODEMODE_HARD_LIMIT_SECONDS";
122
-
123
128
  /**
124
129
  * Bash parity: `terminal/tools/foreground-window.ts` auto-detaches a still-running bash command to a
125
130
  * background session at 60s regardless of its `timeout` kill deadline. An eval cell gets the same
@@ -128,8 +133,6 @@ export const HARD_LIMIT_ENVIRONMENT_FLAG = "SENPI_CODEMODE_HARD_LIMIT_SECONDS";
128
133
  */
129
134
  export const DEFAULT_FOREGROUND_WINDOW_SECONDS = 60;
130
135
 
131
- export const FOREGROUND_WINDOW_ENVIRONMENT_FLAG = "SENPI_CODEMODE_FOREGROUND_SECONDS";
132
-
133
136
  /**
134
137
  * One language kernel runs one cell at a time and a killed JavaScript cell that cannot settle
135
138
  * cooperatively restarts its worker, so a runaway cell costs far more than a runaway bash command:
@@ -137,11 +140,6 @@ export const FOREGROUND_WINDOW_ENVIRONMENT_FLAG = "SENPI_CODEMODE_FOREGROUND_SEC
137
140
  */
138
141
  export const DEFAULT_RUN_BUDGET_SECONDS = 300;
139
142
 
140
- export const DEFAULT_MAX_DETACHED_CELLS = 15;
141
-
142
- export const RUN_BUDGET_ENVIRONMENT_FLAG = "SENPI_CODEMODE_RUN_BUDGET_SECONDS";
143
- export const MAX_DETACHED_CELLS_ENVIRONMENT_FLAG = "SENPI_CODEMODE_MAX_DETACHED_CELLS";
144
-
145
143
  // OMP settings-schema.ts:3211-3299 has language/path settings only; eval.ts:427
146
144
  // defaults timeout to 30s, and codemode pins concurrency-bridge.ts:30 width to 4.
147
145
  export const defaultCodemodeSettings: ResolvedCodemodeSettings = {
@@ -169,15 +167,6 @@ export const defaultCodemodeSettings: ResolvedCodemodeSettings = {
169
167
  memory: validatedMemorySettings(undefined).settings,
170
168
  };
171
169
 
172
- const languageEnvironmentFlags = {
173
- py: "SENPI_CODEMODE_PY",
174
- js: "SENPI_CODEMODE_JS",
175
- rb: "SENPI_CODEMODE_RB",
176
- jl: "SENPI_CODEMODE_JL",
177
- } as const;
178
-
179
- export type Environment = Readonly<Record<string, string | undefined>>;
180
-
181
170
  export async function loadCodemodeSettings(options: LoadCodemodeSettingsOptions = {}): Promise<LoadedCodemodeSettings> {
182
171
  const cwd = options.cwd ?? process.cwd();
183
172
  const homeDir = options.homeDir ?? homedir();
@@ -193,48 +182,6 @@ export async function loadCodemodeSettings(options: LoadCodemodeSettingsOptions
193
182
  return { settings: defaultCodemodeSettings, source: null, warnings: [] };
194
183
  }
195
184
 
196
- export function resolveEnabledLanguages(
197
- settings: CodemodeSettings,
198
- env: Environment = process.env,
199
- ): CodemodeSettings["languages"] {
200
- return {
201
- py: resolveLanguage(settings.languages.py, env[languageEnvironmentFlags.py]),
202
- js: resolveLanguage(settings.languages.js, env[languageEnvironmentFlags.js]),
203
- rb: resolveLanguage(settings.languages.rb, env[languageEnvironmentFlags.rb]),
204
- jl: resolveLanguage(settings.languages.jl, env[languageEnvironmentFlags.jl]),
205
- };
206
- }
207
-
208
- /** Environment override wins over the settings file; a non-positive or malformed value is ignored. */
209
- export function resolveHardLimitSeconds(settings: CodemodeSettings, env: Environment = process.env): number {
210
- return positiveSecondsOverride(env[HARD_LIMIT_ENVIRONMENT_FLAG]) ?? settings.hardLimitSeconds;
211
- }
212
-
213
- /** Environment override wins over the settings file; a non-positive or malformed value is ignored. */
214
- export function resolveForegroundWindowSeconds(settings: CodemodeSettings, env: Environment = process.env): number {
215
- return positiveSecondsOverride(env[FOREGROUND_WINDOW_ENVIRONMENT_FLAG]) ?? settings.foregroundWindowSeconds;
216
- }
217
-
218
- /** Environment override wins over the settings file; a non-positive or malformed value is ignored. */
219
- export function resolveRunBudgetSeconds(settings: CodemodeSettings, env: Environment = process.env): number {
220
- return positiveSecondsOverride(env[RUN_BUDGET_ENVIRONMENT_FLAG]) ?? settings.runBudgetSeconds;
221
- }
222
-
223
- /** Uses the same positive-integer environment parsing as the run budget. */
224
- export function resolveMaxDetachedCells(settings: CodemodeSettings, env: Environment = process.env): number {
225
- return (
226
- positiveSecondsOverride(env[MAX_DETACHED_CELLS_ENVIRONMENT_FLAG]) ??
227
- settings.maxDetachedCells ??
228
- DEFAULT_MAX_DETACHED_CELLS
229
- );
230
- }
231
-
232
- function positiveSecondsOverride(value: string | undefined): number | undefined {
233
- if (value === undefined) return undefined;
234
- const parsed = Number.parseInt(value, 10);
235
- return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
236
- }
237
-
238
185
  async function loadSettingsFile(path: string): Promise<LoadedCodemodeSettings> {
239
186
  const raw = await readFile(path, "utf8");
240
187
  let parsed: unknown;
@@ -249,15 +196,21 @@ async function loadSettingsFile(path: string): Promise<LoadedCodemodeSettings> {
249
196
  };
250
197
  }
251
198
 
252
- if (!Check(codemodeSettingsSchema, parsed)) {
199
+ const known = withoutUnknownTopLevelKeys(parsed, new Set(Object.keys(codemodeSettingsSchema.properties)), path);
200
+ const candidate = known.value;
201
+ if (!Check(codemodeSettingsSchema, candidate)) {
253
202
  return {
254
203
  settings: defaultCodemodeSettings,
255
204
  source: path,
256
- warnings: [`Invalid codemode settings in ${path}. Falling back to codemode defaults.`],
205
+ warnings: [...known.warnings, `Invalid codemode settings in ${path}. Falling back to codemode defaults.`],
257
206
  };
258
207
  }
259
208
 
260
- return { settings: mergeSettings(parsed), source: path, warnings: validatedMemorySettings(parsed.memory).warnings };
209
+ return {
210
+ settings: mergeSettings(candidate),
211
+ source: path,
212
+ warnings: [...known.warnings, ...validatedMemorySettings(candidate.memory).warnings],
213
+ };
261
214
  }
262
215
 
263
216
  function mergeSettings(input: CodemodeSettingsInput): ResolvedCodemodeSettings {
@@ -267,6 +220,7 @@ function mergeSettings(input: CodemodeSettingsInput): ResolvedCodemodeSettings {
267
220
  js: input.languages?.js ?? defaultCodemodeSettings.languages.js,
268
221
  rb: input.languages?.rb ?? defaultCodemodeSettings.languages.rb,
269
222
  jl: input.languages?.jl ?? defaultCodemodeSettings.languages.jl,
223
+ ...(input.languages?.pyInterpreter === undefined ? {} : { pyInterpreter: input.languages.pyInterpreter }),
270
224
  },
271
225
  cellTimeoutSeconds: input.cellTimeoutSeconds ?? defaultCodemodeSettings.cellTimeoutSeconds,
272
226
  foregroundWindowSeconds: input.foregroundWindowSeconds ?? defaultCodemodeSettings.foregroundWindowSeconds,
@@ -284,23 +238,10 @@ function mergeSettings(input: CodemodeSettingsInput): ResolvedCodemodeSettings {
284
238
  },
285
239
  statusEvents: input.statusEvents ?? defaultCodemodeSettings.statusEvents,
286
240
  memory: validatedMemorySettings(input.memory).settings,
241
+ ...pickFeatureSettings(input),
287
242
  };
288
243
  }
289
244
 
290
- function resolveLanguage(fileSetting: boolean, environmentValue: string | undefined): boolean {
291
- if (environmentValue === undefined) return fileSetting;
292
- switch (environmentValue.trim().toLowerCase()) {
293
- case "0":
294
- case "false":
295
- return false;
296
- case "1":
297
- case "true":
298
- return true;
299
- default:
300
- return fileSetting;
301
- }
302
- }
303
-
304
245
  async function fileExists(path: string): Promise<boolean> {
305
246
  try {
306
247
  await access(path);
@@ -309,3 +250,19 @@ async function fileExists(path: string): Promise<boolean> {
309
250
  return false;
310
251
  }
311
252
  }
253
+
254
+ export {
255
+ DEFAULT_MAX_DETACHED_CELLS,
256
+ type Environment,
257
+ FOREGROUND_WINDOW_ENVIRONMENT_FLAG,
258
+ HARD_LIMIT_ENVIRONMENT_FLAG,
259
+ MAX_DETACHED_CELLS_ENVIRONMENT_FLAG,
260
+ RUN_BUDGET_ENVIRONMENT_FLAG,
261
+ } from "./settings-constants.ts";
262
+ export {
263
+ resolveEnabledLanguages,
264
+ resolveForegroundWindowSeconds,
265
+ resolveHardLimitSeconds,
266
+ resolveMaxDetachedCells,
267
+ resolveRunBudgetSeconds,
268
+ } from "./settings-overrides.ts";
@@ -0,0 +1,19 @@
1
+ /**
2
+ * A settings file written for a newer senpi may name keys this version does not know: they are dropped with
3
+ * one warning each instead of failing the whole file, so the user's other settings still apply. Only the
4
+ * top level is lenient; the known nested objects keep their strict schemas.
5
+ */
6
+ export function withoutUnknownTopLevelKeys(
7
+ parsed: unknown,
8
+ known: ReadonlySet<string>,
9
+ path: string,
10
+ ): { readonly value: unknown; readonly warnings: string[] } {
11
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return { value: parsed, warnings: [] };
12
+ const kept: Record<string, unknown> = {};
13
+ const warnings: string[] = [];
14
+ for (const [key, value] of Object.entries(parsed)) {
15
+ if (known.has(key)) kept[key] = value;
16
+ else warnings.push(`Unknown codemode setting "${key}" in ${path} is ignored; the other settings still apply.`);
17
+ }
18
+ return { value: kept, warnings };
19
+ }
@@ -0,0 +1,37 @@
1
+ import { isReservedToolName, runReservedTool } from "../bridges/reserved-dispatch.ts";
2
+ import { defaultCodemodeSettings } from "../config/settings.ts";
3
+ import { marshalToolResult } from "../tool/image.ts";
4
+ import type { CreateCodemodeSessionManagerOptions } from "./session-manager-contract.ts";
5
+
6
+ export interface BridgeToolCallRequest {
7
+ readonly toolName: string;
8
+ readonly args: unknown;
9
+ readonly callId: string;
10
+ readonly signal: AbortSignal;
11
+ }
12
+
13
+ // Subprocess kernels (py/rb/jl) reach the host only through this route, so every reply
14
+ // must match the in-process JS path in tool/cell-handler.ts: reserved helper names dispatch
15
+ // through runReservedTool (forwarding them made agent() fail with "Unknown tool __agent__"),
16
+ // and ordinary tool results are marshalled to { text, images, details, hasError } — the raw
17
+ // { content } shape left python cells unable to reach tool.read image blocks.
18
+ export async function routeBridgeToolCall(
19
+ options: Pick<CreateCodemodeSessionManagerOptions, "executeTool" | "listTools" | "settings">,
20
+ request: BridgeToolCallRequest,
21
+ ): Promise<unknown> {
22
+ if (!isReservedToolName(request.toolName)) {
23
+ return marshalToolResult(await options.executeTool(request.toolName, request.args, { signal: request.signal }));
24
+ }
25
+ const taskTools = options.settings.taskTools ?? defaultCodemodeSettings.taskTools;
26
+ return await runReservedTool(request.toolName, {
27
+ callId: request.callId,
28
+ args: request.args,
29
+ executeTool: options.executeTool,
30
+ taskToolName: taskTools.task,
31
+ taskOutputToolName: taskTools.output,
32
+ listTools: options.listTools,
33
+ signal: request.signal,
34
+ emitStatus: () => {},
35
+ marshalToolResult,
36
+ });
37
+ }