@code-yeongyu/senpi-codemode 2026.10.31-2 → 2026.11.2-1

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
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## [2026.11.02-1] - 2026-11-02
4
+
5
+ ### Breaking Changes
6
+
7
+ ### Added
8
+
9
+ ### Changed
10
+
11
+ - An eval call that leaves out `language` now runs when the kernel is unambiguous (only one language enabled, or clear js/py code markers) and its result says the language was inferred ([#5157](https://github.com/code-yeongyu/senpi/pull/5157))
12
+
13
+ ### Fixed
14
+
15
+ - Python Windows startup supports native builtin path helpers without losing captured permission identity isolation, and the isolated compiled-host check includes all Python runtime assets ([#5052](https://github.com/code-yeongyu/senpi/pull/5052)).
16
+ - A Ruby or Julia kernel whose interpreter cannot be spawned (for example `julia` missing from `PATH`) now fails its cells with the spawn error (`Kernel startup failed: spawn julia ENOENT`) and closes at once, instead of waiting two seconds for an exit that never comes and reporting `Kernel process did not exit after SIGKILL` (#3500) ([#5181](https://github.com/code-yeongyu/senpi/pull/5181))
17
+ - A stopped JavaScript cell no longer runs code from an `fs` callback operation that was already in flight when it was stopped: the callback is dropped (a late `fs.open` descriptor is closed), the same way the stopped cell's timers never fire, so a stopped polling loop cannot tick once more after the stop ([#5179](https://github.com/code-yeongyu/senpi/issues/5179))
18
+
19
+ ### Removed
20
+
3
21
  ## [2026.10.31-2] - 2026-10-31
4
22
 
5
23
  ### Breaking Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-codemode",
3
- "version": "2026.10.31-2",
3
+ "version": "2026.11.02-1",
4
4
  "description": "Source-only senpi extension package for codemode evaluation tools",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -33,15 +33,15 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@babel/parser": "8.0.4",
36
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.31-2",
36
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.11.02-1",
37
37
  "quickjs-wasi": "3.6.2",
38
38
  "typebox": "1.3.34"
39
39
  },
40
40
  "peerDependencies": {
41
- "@code-yeongyu/senpi": "2026.10.31-2"
41
+ "@code-yeongyu/senpi": "2026.11.02-1"
42
42
  },
43
43
  "devDependencies": {
44
- "@code-yeongyu/senpi": "2026.10.31-2"
44
+ "@code-yeongyu/senpi": "2026.11.02-1"
45
45
  },
46
46
  "keywords": [
47
47
  "senpi",
@@ -152,14 +152,23 @@ function refuseWhenReleased(owner, names, patched) {
152
152
  }
153
153
  }
154
154
 
155
- // The callback form: a released cell's operation is never started, so its callback never runs, the same way a timer a
156
- // released cell sets never fires.
155
+ // The callback form: a released cell's operation is never started, and one already in flight when the cell is released
156
+ // never calls back, the same way a timer a released cell sets never fires. A file it opened late is closed instead.
157
157
  function neverStartWhenReleased(owner, names, patched) {
158
158
  for (const name of names) {
159
159
  const original = owner?.[name];
160
160
  if (typeof original !== "function") continue;
161
161
  const guarded = function (...args) {
162
- if (currentCell()?.released) return undefined;
162
+ const cell = currentCell();
163
+ if (cell?.released) return undefined;
164
+ const callback = args.at(-1);
165
+ if (cell !== undefined && typeof callback === "function") {
166
+ args[args.length - 1] = function (error, ...results) {
167
+ if (!cell.released) return callback.call(this, error, ...results);
168
+ if (name === "open" && error == null && Number.isInteger(results[0])) owner.close(results[0], () => {});
169
+ return undefined;
170
+ };
171
+ }
163
172
  return original.apply(this, args);
164
173
  };
165
174
  Object.assign(guarded, original);
@@ -27,7 +27,9 @@ def windows_primitives(failure):
27
27
  if isinstance(function, types.FunctionType) and function.__globals__ is vars(ntpath):
28
28
  namespace[name] = types.FunctionType(function.__code__, namespace, name, function.__defaults__)
29
29
  dirname, basename, normpath, splitdrive = (
30
- types.FunctionType(getattr(ntpath, name).__code__, namespace, name)
30
+ # C builtins do not read ntpath's mutable module globals.
31
+ types.FunctionType(function.__code__, namespace, name, function.__defaults__)
32
+ if isinstance(function := getattr(ntpath, name), types.FunctionType) else function
31
33
  for name in ("dirname", "basename", "normpath", "splitdrive")
32
34
  )
33
35
  getcwd = os.getcwd
@@ -231,7 +231,10 @@ export class SubprocessKernel {
231
231
  onStderr: (source, data) => this.handleMessage(source, { type: "text", stream: "stderr", data }),
232
232
  onExit: (source, code, signal) => this.handleExit(source, code, signal),
233
233
  onError: (source, error) => {
234
- if (this.accepts(source)) this.failClosed(new KernelProcessError(error.message));
234
+ if (!this.accepts(source)) return;
235
+ this.failClosed(
236
+ source.neverStarted ? new KernelStartupError(error.message) : new KernelProcessError(error.message),
237
+ );
235
238
  },
236
239
  });
237
240
  this.process = process;
@@ -52,6 +52,7 @@ export class SubprocessProcess {
52
52
  private readonly errorListener: (error: Error) => void;
53
53
  private readonly stdinErrorListener: (error: Error) => void;
54
54
  private exited = false;
55
+ private spawnFailed = false;
55
56
  private retiring = false;
56
57
  private outputDetached = false;
57
58
  private errorReported = false;
@@ -68,6 +69,13 @@ export class SubprocessProcess {
68
69
  this.errorListener = (error) => {
69
70
  if (this.errorReported) return;
70
71
  this.errorReported = true;
72
+ // senpi#3500: a child whose spawn failed has no pid and never emits "exit"; there is no process to retire.
73
+ if (child.pid === undefined && String(Reflect.get(error, "syscall")).startsWith("spawn")) {
74
+ this.spawnFailed = true;
75
+ this.exited = true;
76
+ this.detachOutput();
77
+ this.exit.resolve();
78
+ }
71
79
  this.handlers.onError(this, error);
72
80
  };
73
81
  this.stdoutReader.on("line", (line) => {
@@ -96,6 +104,10 @@ export class SubprocessProcess {
96
104
  return this.retiring;
97
105
  }
98
106
 
107
+ get neverStarted(): boolean {
108
+ return this.spawnFailed;
109
+ }
110
+
99
111
  send(frame: string): boolean {
100
112
  if (this.retiring || this.exited) return false;
101
113
  this.child.stdin.write(frame);
@@ -1,5 +1,6 @@
1
1
  import { type JsonObject, registerToolArgumentValidator, validateToolArguments } from "@earendil-works/pi-ai";
2
2
  import type { TSchema } from "typebox";
3
+ import { EVAL_LANGUAGE_INFERRED_KEY, inferEvalLanguage, markEvalLanguageFilled } from "./eval-language-inference.ts";
3
4
  import { type EvalRunField, evalRunFieldRequiredMessage, normalizeEvalSummary } from "./eval-request.ts";
4
5
  import type { EvalLanguage } from "./types.ts";
5
6
 
@@ -17,21 +18,36 @@ const RUN_FIELDS: readonly EvalRunField[] = ["language", "code", "summary"];
17
18
  export function registerEvalArgumentValidator(schema: TSchema, languages: readonly EvalLanguage[]): void {
18
19
  // The same schema under another identity, so the generic path validates it instead of calling back here.
19
20
  const generic: TSchema = Object.create(Object.getPrototypeOf(schema), Object.getOwnPropertyDescriptors(schema));
21
+ const genericValidate = (args: unknown): unknown =>
22
+ validateToolArguments(
23
+ { name: "eval", description: "", parameters: generic },
24
+ { type: "toolCall", id: "", name: "eval", arguments: args as JsonObject },
25
+ );
20
26
  const validate: EvalArgumentValidator = (value) => {
27
+ const callerArguments = structuredClone(value);
28
+ const inferred = fillInferredLanguage(value, languages);
21
29
  const missing = missingRunFieldErrors(value, languages);
22
30
  if (missing.length > 0) {
23
31
  validate.errors = missing;
24
32
  return false;
25
33
  }
26
- // Throws the generic validation error unchanged when the call is invalid for any other reason.
27
- const coerced: unknown = validateToolArguments(
28
- { name: "eval", description: "", parameters: generic },
29
- { type: "toolCall", id: "", name: "eval", arguments: value as JsonObject },
30
- );
34
+ let coerced: unknown;
35
+ try {
36
+ // Throws the generic validation error unchanged when the call is invalid for any other reason.
37
+ coerced = genericValidate(value);
38
+ } catch (error) {
39
+ // A filled language stands only for a call that validates; otherwise report what the caller sent.
40
+ if (inferred !== undefined) genericValidate(callerArguments);
41
+ throw error;
42
+ }
31
43
  // A registered validator's input is what the caller returns, so the generic coercion lands in place.
32
44
  if (isRecord(value) && isRecord(coerced)) {
33
45
  for (const key of Object.keys(value)) delete value[key];
34
46
  Object.assign(value, coerced);
47
+ if (inferred !== undefined) {
48
+ value[EVAL_LANGUAGE_INFERRED_KEY] = inferred;
49
+ markEvalLanguageFilled(value);
50
+ }
35
51
  }
36
52
  validate.errors = null;
37
53
  return true;
@@ -39,6 +55,20 @@ export function registerEvalArgumentValidator(schema: TSchema, languages: readon
39
55
  registerToolArgumentValidator(schema, validate);
40
56
  }
41
57
 
58
+ /**
59
+ * Fills an omitted run language when it is unambiguous (senpi#5154), before the missing-field check, and drops any
60
+ * inference marker the caller sent so only this fill can set it. An explicit language is never changed.
61
+ */
62
+ function fillInferredLanguage(value: unknown, languages: readonly EvalLanguage[]): string | undefined {
63
+ if (!isRecord(value) || (value.action !== undefined && value.action !== "run")) return undefined;
64
+ delete value[EVAL_LANGUAGE_INFERRED_KEY];
65
+ if (!isMissing("language", value.language)) return undefined;
66
+ const inference = inferEvalLanguage(value.code, languages);
67
+ if (inference === undefined) return undefined;
68
+ value.language = inference.language;
69
+ return inference.basis;
70
+ }
71
+
42
72
  function missingRunFieldErrors(value: unknown, languages: readonly EvalLanguage[]): EvalArgumentError[] {
43
73
  if (!isRecord(value) || (value.action !== undefined && value.action !== "run")) return [];
44
74
  return RUN_FIELDS.filter((field) => isMissing(field, value[field])).map((field) => ({
@@ -0,0 +1,116 @@
1
+ import type { EvalLanguage } from "./types.ts";
2
+
3
+ /** Why an omitted run language was filled: the only enabled kernel, or unambiguous code markers (senpi#5154). */
4
+ export type EvalLanguageInferenceBasis = "only-enabled-language" | "code-markers";
5
+
6
+ export interface EvalLanguageInference {
7
+ readonly language: EvalLanguage;
8
+ readonly basis: EvalLanguageInferenceBasis;
9
+ }
10
+
11
+ /** The argument key validation sets when it filled the language, so execution can tell the model. */
12
+ export const EVAL_LANGUAGE_INFERRED_KEY = "language_inferred";
13
+
14
+ // Argument objects whose language this module's validator filled; only these may carry a trusted inference marker.
15
+ const filledArguments = new WeakSet<object>();
16
+
17
+ export function markEvalLanguageFilled(args: object): void {
18
+ filledArguments.add(args);
19
+ }
20
+
21
+ /** True only when argument validation filled this object's language; a caller-sent marker is never trusted. */
22
+ export function wasEvalLanguageFilled(args: object): boolean {
23
+ return filledArguments.has(args);
24
+ }
25
+
26
+ interface LanguageSignals {
27
+ /** Any one of these fills the language when the other language shows no signal at all. */
28
+ readonly strong: readonly RegExp[];
29
+ /** These never fill; they only veto a fill for the other language. */
30
+ readonly weak: readonly RegExp[];
31
+ }
32
+
33
+ const PYTHON_IDENTIFIER = "[A-Za-z_]\\w*";
34
+ const PYTHON_MODULE = `${PYTHON_IDENTIFIER}(?:\\.${PYTHON_IDENTIFIER})*`;
35
+ const PYTHON_IMPORT_ITEM = `${PYTHON_MODULE}(?:[ \\t]+as[ \\t]+${PYTHON_IDENTIFIER})?`;
36
+
37
+ const SIGNALS: Readonly<Record<"js" | "py", LanguageSignals>> = {
38
+ py: {
39
+ strong: [
40
+ /^[ \t]*def[ \t]+[A-Za-z_]\w*[ \t]*\(/mu,
41
+ new RegExp(
42
+ `^[ \\t]*import[ \\t]+${PYTHON_IMPORT_ITEM}(?:[ \\t]*,[ \\t]*${PYTHON_IMPORT_ITEM})*[ \\t]*$`,
43
+ "mu",
44
+ ),
45
+ new RegExp(`^[ \\t]*from[ \\t]+\\.*${PYTHON_MODULE}[ \\t]+import[ \\t]+[\\w(*]`, "mu"),
46
+ ],
47
+ weak: [
48
+ /\b(?:None|True|False|elif|lambda|self)\b/u,
49
+ /^[ \t]*(?:if|elif|else|for|while|with|try|except|finally|class|async[ \t]+def)\b.*:[ \t]*(?:#.*)?$/mu,
50
+ /^[ \t]*#/mu,
51
+ ],
52
+ },
53
+ js: {
54
+ strong: [
55
+ // `print(` is a prelude helper in both runtimes, so it is no signal at all; these forms are JS-only.
56
+ /\bawait[ \t]+[A-Za-z_$][\w$.]*[ \t]*\(/u,
57
+ /`/u,
58
+ /(?<![\w.$])(?:JSON|Object|Array)\.[A-Za-z_$]/u,
59
+ /^[ \t]*(?:const|let)[ \t]+[A-Za-z_$[{]/mu,
60
+ /=>/u,
61
+ /\bconsole\.log\(/u,
62
+ /^[ \t]*import[ \t].*\bfrom[ \t]*["']/mu,
63
+ /^[ \t]*import[ \t]*["']/mu,
64
+ ],
65
+ weak: [
66
+ /\b(?:null|undefined|function|var|typeof|new)\b/u,
67
+ /===|!==|&&|\|\||\+\+/u,
68
+ /;[ \t]*$/mu,
69
+ /^[ \t]*\/\//mu,
70
+ /[{}][ \t]*$/mu,
71
+ ],
72
+ },
73
+ };
74
+
75
+ function signals(code: string, language: "js" | "py"): { readonly strong: boolean; readonly any: boolean } {
76
+ const { strong, weak } = SIGNALS[language];
77
+ const hasStrong = strong.some((pattern) => pattern.test(code));
78
+ return { strong: hasStrong, any: hasStrong || weak.some((pattern) => pattern.test(code)) };
79
+ }
80
+
81
+ /**
82
+ * Classifies a cell body as js or py only on a strong marker of one language with no signal of the other;
83
+ * anything else, including any conflicting signal, is unclassified.
84
+ */
85
+ export function classifyEvalCode(code: string): "js" | "py" | undefined {
86
+ const py = signals(code, "py");
87
+ const js = signals(code, "js");
88
+ if (py.strong && !js.any) return "py";
89
+ if (js.strong && !py.any) return "js";
90
+ return undefined;
91
+ }
92
+
93
+ /**
94
+ * The language an omitted run language may be filled with: the only enabled kernel, or, when exactly js and py are
95
+ * enabled, the classified code. Any other case returns undefined and the caller keeps its teaching error.
96
+ */
97
+ export function inferEvalLanguage(
98
+ code: unknown,
99
+ languages: readonly EvalLanguage[],
100
+ ): EvalLanguageInference | undefined {
101
+ const [only] = languages;
102
+ if (languages.length === 1 && only !== undefined) return { language: only, basis: "only-enabled-language" };
103
+ if (languages.length !== 2 || !languages.includes("js") || !languages.includes("py")) return undefined;
104
+ if (typeof code !== "string") return undefined;
105
+ const language = classifyEvalCode(code);
106
+ return language === undefined ? undefined : { language, basis: "code-markers" };
107
+ }
108
+
109
+ export function isEvalLanguageInferenceBasis(value: unknown): value is EvalLanguageInferenceBasis {
110
+ return value === "only-enabled-language" || value === "code-markers";
111
+ }
112
+
113
+ /** The line an inferred-language result leads with, so the model sees the fill and passes language next time. */
114
+ export function evalLanguageInferredNote(language: EvalLanguage, basis: EvalLanguageInferenceBasis): string {
115
+ return `${EVAL_LANGUAGE_INFERRED_KEY}: ${language} (${basis}). This eval run omitted language; pass language explicitly.`;
116
+ }
@@ -6,6 +6,13 @@ import {
6
6
  type EvalToolRequest,
7
7
  evalLanguageOrder,
8
8
  } from "./types.ts";
9
+ import {
10
+ EVAL_LANGUAGE_INFERRED_KEY,
11
+ type EvalLanguageInferenceBasis,
12
+ inferEvalLanguage,
13
+ isEvalLanguageInferenceBasis,
14
+ wasEvalLanguageFilled,
15
+ } from "./eval-language-inference.ts";
9
16
 
10
17
  const NON_INTERACTIVE_MODES = new Set(["print", "json"]);
11
18
 
@@ -40,9 +47,17 @@ export function parseEvalRequest(
40
47
  }
41
48
  if (params.action !== undefined && params.action !== "run")
42
49
  throw new TypeError(`Unknown eval action "${String(params.action)}"`);
43
- if (params.language === undefined) throw new TypeError(evalRunFieldRequiredMessage("language", enabledLanguages));
44
- if (!isEvalLanguage(params.language))
50
+ // An omitted language is filled only when unambiguous (senpi#5154); argument validation may already have filled it.
51
+ const inference = params.language === undefined ? inferEvalLanguage(params.code, enabledLanguages) : undefined;
52
+ const language: unknown = inference?.language ?? params.language;
53
+ if (language === undefined) throw new TypeError(evalRunFieldRequiredMessage("language", enabledLanguages));
54
+ if (!isEvalLanguage(language))
45
55
  throw new TypeError(`eval run language must be one of: ${enabledLanguages.join(", ")}`);
56
+ const inferredBy: EvalLanguageInferenceBasis | undefined =
57
+ inference?.basis ??
58
+ (wasEvalLanguageFilled(params) && isEvalLanguageInferenceBasis(params[EVAL_LANGUAGE_INFERRED_KEY])
59
+ ? params[EVAL_LANGUAGE_INFERRED_KEY]
60
+ : undefined);
46
61
  if (typeof params.code !== "string") throw new TypeError(evalRunFieldRequiredMessage("code", enabledLanguages));
47
62
  const summary = normalizeEvalSummary(params.summary);
48
63
  if (summary === undefined) throw new TypeError(evalRunFieldRequiredMessage("summary", enabledLanguages));
@@ -53,12 +68,11 @@ export function parseEvalRequest(
53
68
  if (options.sandbox !== true) {
54
69
  throw new EvalIsolateInvalidError("sandbox cells are turned off (set sandbox.enabled in codemode settings)");
55
70
  }
56
- if (params.language !== "js")
57
- throw new EvalIsolateInvalidError('sandbox cells run JavaScript only (language: "js")');
71
+ if (language !== "js") throw new EvalIsolateInvalidError('sandbox cells run JavaScript only (language: "js")');
58
72
  if (params.reset === true) throw new EvalIsolateInvalidError("a sandbox cell starts fresh; reset does not apply");
59
73
  }
60
74
  return {
61
- language: params.language,
75
+ language,
62
76
  code: params.code,
63
77
  summary,
64
78
  ...(params.action === "run" ? { action: "run" as const } : {}),
@@ -66,6 +80,7 @@ export function parseEvalRequest(
66
80
  ...(params.on_timeout === "detach" || params.on_timeout === "error" ? { on_timeout: params.on_timeout } : {}),
67
81
  ...(typeof params.reset === "boolean" ? { reset: params.reset } : {}),
68
82
  ...(params.isolate === true ? { isolate: true } : {}),
83
+ ...(inferredBy === undefined ? {} : { language_inferred: inferredBy }),
69
84
  };
70
85
  }
71
86
 
@@ -9,6 +9,7 @@ import {
9
9
  import { buildEvalPrompt } from "../prompt/eval-prompt.ts";
10
10
  import { EvalDetachedCellManager } from "./detached-cell-manager.ts";
11
11
  import { registerEvalArgumentValidator } from "./eval-argument-validator.ts";
12
+ import { evalLanguageInferredNote } from "./eval-language-inference.ts";
12
13
  import { executeEvalControl } from "./detached-eval-result.ts";
13
14
  import {
14
15
  EvalIsolateInvalidError,
@@ -38,6 +39,7 @@ export type { EnabledEvalLanguages, EvalKernel, EvalKernelManager } from "./type
38
39
  type EvalExecuteArgs<Request extends EvalToolRequest> = Parameters<
39
40
  ToolDefinition<TUnsafe<Request | Partial<EvalToolInput>>, EvalToolDetails>["execute"]
40
41
  >;
42
+ type RunArgs = EvalExecuteArgs<EvalToolInput>;
41
43
 
42
44
  export function createEvalTool(options: CreateEvalToolOptions) {
43
45
  const foregroundWindowSeconds = options.foregroundWindowSeconds ?? DEFAULT_FOREGROUND_WINDOW_SECONDS;
@@ -87,6 +89,19 @@ export function createEvalTool(options: CreateEvalToolOptions) {
87
89
  ): Promise<AgentToolResult<EvalResultDetails>> {
88
90
  const request = parseEvalRequest(params, languages, { sandbox });
89
91
  if (isEvalControlRequest(request)) return await executeEvalControl(cellManager, request);
92
+ const output = await executeRun(request, toolCallId, signal, onUpdate, ctx);
93
+ if (request.language_inferred === undefined) return output;
94
+ // The model sees that its omitted language was filled, and how, so it passes language next time (senpi#5154).
95
+ const note = evalLanguageInferredNote(request.language, request.language_inferred);
96
+ return { ...output, content: [{ type: "text", text: note }, ...output.content] };
97
+ }
98
+ async function executeRun(
99
+ request: EvalToolInput,
100
+ toolCallId: RunArgs[0],
101
+ signal: RunArgs[2],
102
+ onUpdate: RunArgs[3],
103
+ ctx: RunArgs[4],
104
+ ): Promise<AgentToolResult<EvalResultDetails>> {
90
105
  if (options.proxyExecutor) {
91
106
  // A proxy runs cells elsewhere and knows nothing of sandbox cells: an isolated cell must never run unisolated.
92
107
  if ("isolate" in request && request.isolate === true)
package/src/tool/types.ts CHANGED
@@ -59,6 +59,8 @@ export interface EvalToolInput {
59
59
  readonly reset?: boolean;
60
60
  /** Run in a fresh sandbox VM instead of the persistent kernel; accepted only while `sandbox.enabled` is on. */
61
61
  readonly isolate?: boolean;
62
+ /** Set when the call omitted language and it was filled (senpi#5154); the result then says so. */
63
+ readonly language_inferred?: "only-enabled-language" | "code-markers";
62
64
  }
63
65
 
64
66
  export interface EvalListInput {