@code-yeongyu/senpi-codemode 2026.7.25-2 → 2026.7.26

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,29 @@
12
12
 
13
13
  ### Removed
14
14
 
15
+ ## [2026.7.26] - 2026-07-26
16
+
17
+ ### Breaking Changes
18
+
19
+ - Remove the separate GPT-only `exec`/`wait` runtime; GPT models now compose active tools through the persistent `eval` surface.
20
+
21
+ ### Added
22
+
23
+ - Detach interactive `eval` cells on timeout, inject completion notifications, and support `peek`/`stop` actions without blocking other language kernels.
24
+ - Report whether Python kernel state survived an interrupt or timeout, with a real-surface QA driver covering the contract.
25
+
26
+ ### Changed
27
+
28
+ - Bound each cell's retained status history and summarize omitted events ([#334](https://github.com/code-yeongyu/senpi/pull/334) by [@minpeter](https://github.com/minpeter)).
29
+ - Make task-output lookups non-blocking and document detached-cell state, output, and artifact behavior.
30
+
31
+ ### Fixed
32
+
33
+ - Preserve Python state when interruption succeeds, report truthful state when it does not, and tolerate kernels predating the interrupt-outcome contract.
34
+ - Stop normal bridge-request completion from aborting still-running host tool calls.
35
+
36
+ ### Removed
37
+
15
38
  ## [2026.7.25-2] - 2026-07-25
16
39
 
17
40
  ### Breaking Changes
package/README.md CHANGED
@@ -1,15 +1,19 @@
1
1
  # @code-yeongyu/senpi-codemode
2
2
 
3
3
  `@code-yeongyu/senpi-codemode` is Senpi's source-only Code Mode extension. It
4
- registers persistent-kernel `eval` for every eligible model and GPT-only
5
- `exec`/`wait` cells for bounded JavaScript orchestration. `eval` owns one
6
- persistent kernel per enabled language and re-registers at session start after
7
- configuration, interpreter availability, and active task-tool names are known.
4
+ registers the persistent-kernel `eval` execution surface for every eligible
5
+ model. `eval` owns one persistent kernel per enabled language and re-registers
6
+ at session start after configuration, interpreter availability, and active
7
+ task-tool names are known.
8
8
 
9
9
  ## Capabilities
10
10
 
11
11
  - Persistent JavaScript, Python, Ruby, and Julia cells. State survives later
12
12
  cells in the same language until reset, restart, or session disposal.
13
+ - Timeout detachment for interactive `eval`: long pure-compute cells return a
14
+ handle and continue in their existing kernel. Completion is injected with the
15
+ final value/error and buffered output; use `eval({ action: "peek"|"stop",
16
+ cell_id })` to inspect or terminate a detached cell.
13
17
  - Loopback, bearer-authenticated kernel bridge with bounded JSONL frames.
14
18
  - Structured status events for file operations, environment access, phases,
15
19
  bridge activity, and delegated task progress.
@@ -20,8 +24,8 @@ configuration, interpreter availability, and active task-tool names are known.
20
24
  fallbacks.
21
25
  - JavaScript import rewriting for supported local modules and package imports
22
26
  in the persistent Node.js worker.
23
- - GPT Code Mode `exec`/`wait` cells use fresh JavaScript workers, can compose
24
- active tools through `tools.<name>(args)`, and never replace `eval`.
27
+ - GPT models receive a terse `eval` prompt dialect that prioritizes composing
28
+ active tools through `tool.<name>(args)` and documents detach-on-timeout.
25
29
 
26
30
  ## Kernels
27
31
 
@@ -68,13 +72,13 @@ Configuration is loaded in this order:
68
72
  | Key | Default | Effect |
69
73
  | --- | --- | --- |
70
74
  | `languages` | `py`/`js` enabled; `rb`/`jl` disabled | Selects desired languages before interpreter detection. |
71
- | `cellTimeoutSeconds` | `30` | Idle timeout for one cell unless the call supplies `timeout`. |
75
+ | `cellTimeoutSeconds` | `30` | Idle timeout for one cell unless the call supplies `timeout`; interactive calls detach by default and print/json calls error. |
72
76
  | `parallelPoolWidth` | `4` | Maximum concurrent `parallel()` thunks. |
73
77
  | `taskTools.task` | `"task"` | Registered tool name used by `agent()`. |
74
78
  | `taskTools.output` | `"task_output"` | Registered tool name used by `output()`. |
75
79
  | `outputSink.headBytes` | `20480` | Bytes retained from the beginning of a middle-truncated preview; `0` disables it. |
76
80
  | `outputSink.maxColumns` | `768` | Maximum rendered output columns; `0` disables column clamping. |
77
- | `statusEvents` | `true` | Enables kernel status-event forwarding and rendering. |
81
+ | `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. |
78
82
 
79
83
  `SENPI_CODEMODE_PY`, `SENPI_CODEMODE_JS`, `SENPI_CODEMODE_RB`, and
80
84
  `SENPI_CODEMODE_JL` override the corresponding file setting. `1` or `true`
@@ -96,7 +100,7 @@ options object and asynchronous helpers are `await`-able.
96
100
  | `read(path, offset?, limit?)` | Reads text with 1-indexed line slicing. `local://` paths resolve under the session artifact root. |
97
101
  | `write(path, content)` | Creates parent directories and writes text. `local://` paths persist in the session artifact root. |
98
102
  | `env(key?, value?)` | Reads all kernel environment values, one value, or sets one value. |
99
- | `tool.<name>(args)` / `tools.<name>(args)` | Invokes an active Senpi tool through the normal `pi.executeTool` pipeline. `tools` is the GPT Code Mode spelling. |
103
+ | `tool.<name>(args)` | Invokes an active Senpi tool through the normal `pi.executeTool` pipeline. |
100
104
  | `completion(prompt, model?, system?, schema?)` | Requests a one-shot host completion; `schema` asks the host to parse structured output. |
101
105
  | `agent(prompt, ...)` | Delegates to the configured active `taskTools.task` tool. Supports background handles and structured JSON results. |
102
106
  | `output(ids, format?, offset?, limit?)` | Delegates transcript retrieval to the configured active `taskTools.output` tool. |
@@ -105,13 +109,31 @@ options object and asynchronous helpers are `await`-able.
105
109
  | `log(message)` / `phase(title)` | Emits progress text and starts a status phase. |
106
110
 
107
111
  `agent()` is available only when the configured task tool is active in the
108
- session. `output()` similarly requires the configured task-output tool. Missing
109
- tools produce a clear availability error instead of importing an orchestration
110
- package. `agent()` delegates through the tool contract, so task-engine
111
- permissions, progress updates, and transcripts remain owned by that engine.
112
+ session. `output()` similarly requires the configured task-output tool and
113
+ returns immediately: a running task reports its current status, while completed
114
+ tasks return the requested transcript. Missing tools produce a clear
115
+ availability error instead of importing an orchestration package. `agent()`
116
+ delegates through the tool contract, so task-engine permissions, progress
117
+ updates, and transcripts remain owned by that engine.
112
118
  `isolated`, `apply`, and `merge` are accepted for compatibility but emit a
113
119
  warning because this task-engine integration has no isolation model.
114
120
 
121
+ ## Detached cells
122
+
123
+ `eval` accepts `on_timeout: "detach"|"error"`. The default is `"detach"` in
124
+ interactive TUI, RPC, and app-server sessions; print and JSON one-shot runs
125
+ default to `"error"` so their result is never silently detached. A detached
126
+ cell keeps only its own language kernel busy. A new same-language call returns
127
+ a busy error with its cell id and output tail; calls in other languages continue
128
+ normally. Do not re-run the cell.
129
+
130
+ Use `eval({ action: "peek", cell_id })` for its state and buffered output, or
131
+ `eval({ action: "stop", cell_id })` to cancel it. Python stop interrupts the
132
+ existing kernel and preserves variables. JavaScript stop kills and restarts its
133
+ worker, so JavaScript VM state is lost. Detached completion messages state when
134
+ kernel variables are available to the next eval cell; oversized buffered output
135
+ is written under the session local root and referenced as `local://…`.
136
+
115
137
  ## Output and artifacts
116
138
 
117
139
  Cell output is streamed while the cell runs. Large streams spill to an absolute
@@ -139,11 +161,9 @@ Session generations fence retired kernels and callbacks; each cell settles once
139
161
  across completion, errors, cancellation, timeout, bridge failure, or a kernel
140
162
  crash.
141
163
 
142
- GPT Code Mode is not an additional sandbox: it uses the same Node worker trust
143
- boundary as JavaScript `eval`. Its separate cell lifecycle exists so a yielded
144
- `exec` can be observed or terminated with `wait` without affecting persistent
145
- `eval` kernels. `eval`, `exec`, and `wait` are excluded from the nested tool
146
- namespace to prevent recursive Code Mode execution.
164
+ GPT models use the same JavaScript `eval` worker trust boundary as other JavaScript cells;
165
+ there is no separate execution runtime. `eval` is excluded from the nested tool
166
+ namespace to prevent recursive execution.
147
167
 
148
168
  ## Validation
149
169
 
@@ -157,5 +177,5 @@ npm run check
157
177
 
158
178
  Direct real-surface QA drivers live in `scripts/qa-*.ts`: kernel cells
159
179
  (`qa-py-cell.ts`, `qa-js-cell.ts`, `qa-rb-cell.ts`, `qa-jl-cell.ts`), end-to-end
160
- extension execution (`qa-e2e-eval.ts`, `qa-gpt-code-mode.ts`), and renderer output
180
+ extension execution (`qa-e2e-eval.ts`), and renderer output
161
181
  (`qa-render-dump.ts`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-codemode",
3
- "version": "2026.7.25-2",
3
+ "version": "2026.7.26",
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": "7.29.7",
33
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@^2026.7.25-2",
33
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@^2026.7.26",
34
34
  "typebox": "1.1.38"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@code-yeongyu/senpi": "*"
38
38
  },
39
39
  "devDependencies": {
40
- "@code-yeongyu/senpi": "2026.7.25-2"
40
+ "@code-yeongyu/senpi": "2026.7.26"
41
41
  },
42
42
  "keywords": [
43
43
  "senpi",
@@ -75,7 +75,12 @@ async function handleRequest(
75
75
  options: BridgeServerOptions,
76
76
  ): Promise<void> {
77
77
  const abortController = new AbortController();
78
- request.on("close", () => abortController.abort());
78
+ // IncomingMessage "close" fires on normal message completion in Node >= 16,
79
+ // so premature disconnect must be detected on the response side instead:
80
+ // its "close" without a finished response means the connection died mid-call.
81
+ response.on("close", () => {
82
+ if (!response.writableFinished) abortController.abort();
83
+ });
79
84
  if (request.method !== "POST") {
80
85
  sendJson(response, 404, { ok: false, error: transportError("not_found", "Bridge route was not found") });
81
86
  return;
@@ -62,7 +62,6 @@ export async function runEvalOutput(args: unknown, options: RunEvalOutputOptions
62
62
  {
63
63
  ...(id.startsWith("st_") ? { task_id: id } : { name: id }),
64
64
  mode,
65
- block: true,
66
65
  },
67
66
  options.signal === undefined ? undefined : { signal: options.signal },
68
67
  );
@@ -0,0 +1,40 @@
1
+ import type { ExtensionContext } from "@code-yeongyu/senpi";
2
+ import type { EvalDetachedCellNotification, EvalDetachedCellNotifier } from "../tool/detached-cell-manager.ts";
3
+
4
+ const NON_INTERACTIVE_MODES = new Set(["print", "json"]);
5
+
6
+ export type EvalNotifyMode = "wake" | "next-turn" | "off";
7
+
8
+ export interface EvalNotifierDeps {
9
+ readonly sendUserMessage: (content: string, options?: { deliverAs?: "steer" | "followUp" }) => void;
10
+ readonly getContext: () => ExtensionContext | undefined;
11
+ readonly getMode: () => EvalNotifyMode;
12
+ }
13
+
14
+ /** Session-scoped completion injector with the same no-spin guards as terminal notifications. */
15
+ export class EvalNotifier implements EvalDetachedCellNotifier {
16
+ readonly #deps: EvalNotifierDeps;
17
+ readonly #notified = new Set<string>();
18
+
19
+ constructor(deps: EvalNotifierDeps) {
20
+ this.#deps = deps;
21
+ }
22
+
23
+ /** Starts a fresh session generation without suppressing reused tool-call ids. */
24
+ reset(): void {
25
+ this.#notified.clear();
26
+ }
27
+
28
+ notify(cells: readonly EvalDetachedCellNotification[]): void {
29
+ const mode = this.#deps.getMode();
30
+ if (mode === "off") return;
31
+ const ctx = this.#deps.getContext();
32
+ if (ctx === undefined || NON_INTERACTIVE_MODES.has(ctx.mode) || ctx.model === undefined) return;
33
+ const pending = cells.filter((cell) => !this.#notified.has(cell.cellId));
34
+ if (pending.length === 0) return;
35
+ for (const cell of pending) this.#notified.add(cell.cellId);
36
+ this.#deps.sendUserMessage(pending.map((cell) => cell.content).join("\n\n"), {
37
+ deliverAs: mode === "wake" ? "steer" : "followUp",
38
+ });
39
+ }
40
+ }
package/src/index.ts CHANGED
@@ -1,10 +1,9 @@
1
1
  import * as os from "node:os";
2
2
  import type { ExtensionContext } from "@code-yeongyu/senpi";
3
3
  import type { AgentExecuteTool } from "./bridges/agent-bridge.ts";
4
- import { CodeModeSessionRuntime } from "./codemode/runtime.ts";
5
- import { type CodeModeTool, createCodeModeTools, isGptCodeModeModel } from "./codemode/tools.ts";
6
4
  import { type CompletionRequest, type CompletionResult, createCompletionHandler } from "./completion/handler.ts";
7
5
  import { defaultCodemodeSettings } from "./config/settings.ts";
6
+ import { EvalNotifier } from "./extension/eval-notifier.ts";
8
7
  import {
9
8
  createExecuteTool,
10
9
  createRuntime,
@@ -13,6 +12,7 @@ import {
13
12
  } from "./extension/runtime-factory.ts";
14
13
  import type { CodemodeSessionManager, CreateCodemodeSessionManagerOptions } from "./extension/session-manager.ts";
15
14
  import { SessionManagerProxy } from "./extension/session-manager-proxy.ts";
15
+ import { EvalDetachedCellManager } from "./tool/detached-cell-manager.ts";
16
16
  import { createEvalTool } from "./tool/eval-tool.ts";
17
17
  import { renderEvalCall, renderEvalResult } from "./tool/render.ts";
18
18
 
@@ -29,11 +29,11 @@ type CodemodeEvent = SessionLifecycleEvent | "model_select";
29
29
 
30
30
  export interface CodemodeExtensionAPI {
31
31
  registerTool(tool: ReturnType<typeof createEvalTool>): void;
32
+ registerRemovedToolHint(name: string, hint: string): void;
32
33
  on(event: CodemodeEvent, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void): void;
33
34
  executeTool: AgentExecuteTool;
34
35
  getActiveTools(): string[];
35
- setActiveTools?(toolNames: string[]): void | Promise<void>;
36
- getAllTools?(): readonly { readonly name: string }[];
36
+ sendUserMessage(content: string, options?: { deliverAs?: "steer" | "followUp" }): void;
37
37
  }
38
38
 
39
39
  export interface SenpiCodemodeOptions {
@@ -43,19 +43,24 @@ export interface SenpiCodemodeOptions {
43
43
  readonly complete?: (request: CompletionRequest, ctx: ExtensionContext) => Promise<CompletionResult>;
44
44
  }
45
45
 
46
- type DynamicCodeModeExtensionAPI = CodemodeExtensionAPI & {
47
- registerTool(tool: CodeModeTool): void;
48
- setActiveTools(toolNames: string[]): void | Promise<void>;
49
- getAllTools(): readonly { readonly name: string }[];
50
- };
51
-
52
46
  export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCodemodeOptions = {}): void {
53
47
  const manager = new SessionManagerProxy();
54
48
  const complete = options.complete ?? ((request, ctx) => createCompletionHandler()(ctx)(request));
55
49
  const renderers = { renderCall: renderEvalCall, renderResult: renderEvalResult };
56
- let activeRuntime: (SessionRuntime & { readonly codeMode?: CodeModeSessionRuntime }) | undefined;
50
+ let activeRuntime: SessionRuntime | undefined;
57
51
  let activeModelId: string | undefined;
58
- const registerEvalForRuntime = (runtime: SessionRuntime, modelId: string | undefined): void => {
52
+ let activeContext: ExtensionContext | undefined;
53
+ let activeCells: EvalDetachedCellManager | undefined;
54
+ const notifier = new EvalNotifier({
55
+ sendUserMessage: (content, notifyOptions) => pi.sendUserMessage(content, notifyOptions),
56
+ getContext: () => activeContext,
57
+ getMode: () => "wake",
58
+ });
59
+ const registerEvalForRuntime = (
60
+ runtime: SessionRuntime,
61
+ modelId: string | undefined,
62
+ cellManager: EvalDetachedCellManager,
63
+ ): void => {
59
64
  pi.registerTool(
60
65
  createEvalTool({
61
66
  enabledLanguages: runtime.enabledLanguages,
@@ -65,6 +70,7 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
65
70
  complete,
66
71
  settings: runtime.settings,
67
72
  artifactsDir: runtime.artifactsDir,
73
+ cellManager,
68
74
  executionTracker: manager,
69
75
  renderers,
70
76
  spawns: runtime.spawns,
@@ -75,21 +81,13 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
75
81
  );
76
82
  };
77
83
  const dropRuntime = async (): Promise<void> => {
78
- const codeMode = activeRuntime?.codeMode;
84
+ const cells = activeCells;
79
85
  activeRuntime = undefined;
80
86
  activeModelId = undefined;
81
- await Promise.all([manager.dispose(), codeMode?.dispose()]);
82
- };
83
- const activateCodeModeTools = async (runtime: CodeModeSessionRuntime): Promise<void> => {
84
- if (!isDynamicCodeModeExtensionAPI(pi)) return;
85
- const tools = createCodeModeTools({ runtime });
86
- pi.registerTool(tools.exec);
87
- pi.registerTool(tools.wait);
88
- await pi.setActiveTools([...new Set([...pi.getActiveTools(), "exec", "wait"])]);
89
- };
90
- const deactivateCodeModeTools = async (): Promise<void> => {
91
- if (!isDynamicCodeModeExtensionAPI(pi)) return;
92
- await pi.setActiveTools(pi.getActiveTools().filter((name) => name !== "exec" && name !== "wait"));
87
+ activeCells = undefined;
88
+ await cells?.dispose();
89
+ activeContext = undefined;
90
+ await manager.dispose();
93
91
  };
94
92
  pi.registerTool(
95
93
  createEvalTool({
@@ -99,71 +97,53 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
99
97
  executeTool: createExecuteTool(pi),
100
98
  complete,
101
99
  settings: defaultCodemodeSettings,
100
+ cellManager: new EvalDetachedCellManager({ notifier }),
102
101
  executionTracker: manager,
103
102
  renderers,
104
103
  hostLine: hostLine(),
105
104
  }),
106
105
  );
106
+ pi.registerRemovedToolHint(
107
+ "exec",
108
+ 'exec was removed; use eval({ language: "js", code }) instead. Long eval cells detach on timeout and notify when complete.',
109
+ );
110
+ pi.registerRemovedToolHint(
111
+ "wait",
112
+ 'wait was removed; detached eval cells notify when complete. Use eval({ action: "peek"|"stop", cell_id }) to inspect or stop one.',
113
+ );
107
114
 
108
115
  pi.on("session_start", async (event, ctx) => {
109
- const previousCodeMode = activeRuntime?.codeMode;
116
+ const previousCells = activeCells;
117
+ activeCells = undefined;
118
+ await previousCells?.dispose();
110
119
  const generation = manager.beginReplacement();
111
120
  const runtime = await createRuntime(pi, ctx, event, complete, options);
112
121
  const replaced = await manager.replace(generation, runtime.manager);
113
122
  if (!replaced) return;
114
- await previousCodeMode?.dispose();
115
- const codeMode = isGptCodeModeModel(ctx.model?.id)
116
- ? new CodeModeSessionRuntime({
117
- sessionId: runtime.sessionId,
118
- cwd: runtime.cwd,
119
- parallelPoolWidth: runtime.parallelPoolWidth,
120
- executeTool: runtime.executeTool,
121
- })
122
- : undefined;
123
- activeRuntime = { ...runtime, ...(codeMode === undefined ? {} : { codeMode }) };
123
+ notifier.reset();
124
+ activeContext = ctx;
125
+ const cellManager = new EvalDetachedCellManager({ artifactsDir: runtime.artifactsDir, notifier });
126
+ activeCells = cellManager;
127
+ activeRuntime = runtime;
124
128
  activeModelId = ctx.model?.id;
125
- registerEvalForRuntime(activeRuntime, activeModelId);
126
- if (codeMode) await activateCodeModeTools(codeMode);
127
- else await deactivateCodeModeTools();
129
+ registerEvalForRuntime(runtime, activeModelId, cellManager);
128
130
  });
129
131
  pi.on("session_shutdown", async () => dropRuntime());
130
132
  pi.on("session_before_switch", async () => dropRuntime());
131
133
  pi.on("session_before_fork", async () => dropRuntime());
132
- pi.on("model_select", async (event) => {
134
+ pi.on("model_select", async (event, ctx) => {
135
+ activeContext = ctx;
133
136
  const runtime = activeRuntime;
134
137
  if (runtime === undefined) return;
135
138
  const modelId = modelIdFrom(event);
136
139
  if (modelId === undefined || modelId === activeModelId) return;
137
140
  activeModelId = modelId;
138
- registerEvalForRuntime(runtime, modelId);
139
- if (!isGptCodeModeModel(modelId)) {
140
- const { codeMode, ...nextRuntime } = runtime;
141
- await codeMode?.dispose();
142
- activeRuntime = nextRuntime;
143
- await deactivateCodeModeTools();
144
- return;
145
- }
146
- if (runtime.codeMode) {
147
- if (isDynamicCodeModeExtensionAPI(pi)) {
148
- await pi.setActiveTools([...new Set([...pi.getActiveTools(), "exec", "wait"])]);
149
- }
150
- return;
151
- }
152
- const codeMode = new CodeModeSessionRuntime({
153
- sessionId: runtime.sessionId,
154
- cwd: runtime.cwd,
155
- parallelPoolWidth: runtime.parallelPoolWidth,
156
- executeTool: runtime.executeTool,
157
- });
158
- activeRuntime = { ...runtime, codeMode };
159
- await activateCodeModeTools(codeMode);
141
+ const cellManager = activeCells;
142
+ if (cellManager === undefined) return;
143
+ registerEvalForRuntime(runtime, modelId, cellManager);
160
144
  });
161
145
  }
162
146
 
163
- function isDynamicCodeModeExtensionAPI(pi: CodemodeExtensionAPI): pi is DynamicCodeModeExtensionAPI {
164
- return typeof pi.setActiveTools === "function" && typeof pi.getAllTools === "function";
165
- }
166
-
167
147
  function hostLine(): string {
168
148
  const cpu = os.cpus()[0]?.model?.trim();
169
149
  return [`${os.platform()} ${os.arch()}`, cpu, `${os.availableParallelism()} cores`]
@@ -1,4 +1,5 @@
1
1
  import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
2
+ import type { KernelInterruptHandle } from "../../tool/types.ts";
2
3
  import { createInlineWorker, type WorkerLike } from "./inline-worker.ts";
3
4
  import {
4
5
  assertJavaScriptKernelOpen,
@@ -48,14 +49,16 @@ export class JavaScriptKernel {
48
49
  return await promise;
49
50
  }
50
51
 
51
- async interrupt(reason = "interrupted"): Promise<void> {
52
+ async interrupt(reason = "interrupted"): Promise<KernelInterruptHandle> {
52
53
  assertJavaScriptKernelOpen(this.#lifecycle, "interrupt");
53
54
  const active = this.#runs.active;
54
55
  const target = this.#runs.takeInterruptTarget();
55
- if (!target) return;
56
+ if (!target) return { stateRetained: Promise.resolve(true) };
56
57
  if (target === active) this.#clearTimeout();
57
58
  this.#runs.settle(target, stoppedResult(target.input.cellId, `JS cell interrupted: ${reason}`));
58
59
  await this.#restartAfterStop();
60
+ // A restart always replaces the worker VM, so no user global survives.
61
+ return { stateRetained: Promise.resolve(false) };
59
62
  }
60
63
 
61
64
  async reset(): Promise<void> {
@@ -29,4 +29,6 @@ export interface PendingRun {
29
29
  timeoutTimer: NodeJS.Timeout | null;
30
30
  escalationTimer?: NodeJS.Timeout;
31
31
  interruptReason?: string;
32
+ /** Set while an interrupt outcome is pending; resolved once the kernel knows whether state survived. */
33
+ resolveStateRetained?: (retained: boolean) => void;
32
34
  }
@@ -1,3 +1,4 @@
1
+ import type { KernelInterruptHandle } from "../../tool/types.ts";
1
2
  import type { PendingRun, PythonKernelRunOptions, PythonKernelStartOptions, ResultMessage } from "./kernel-contract.ts";
2
3
  import { failedPythonResult, PythonKernelTransport } from "./transport.ts";
3
4
 
@@ -41,7 +42,7 @@ export class PythonKernel {
41
42
  });
42
43
  }
43
44
 
44
- async interrupt(reason = "interrupted"): Promise<void> {
45
+ async interrupt(reason = "interrupted"): Promise<KernelInterruptHandle> {
45
46
  if (this.#failure) throw this.#failure;
46
47
  for (const pending of [...this.#queue]) {
47
48
  pending.interruptReason = reason;
@@ -49,7 +50,8 @@ export class PythonKernel {
49
50
  }
50
51
  const active = this.#active;
51
52
  const transport = this.#transport;
52
- if (!active || !transport || active.interruptReason !== undefined) return;
53
+ if (!active || !transport || active.interruptReason !== undefined)
54
+ return { stateRetained: Promise.resolve(true) };
53
55
  active.interruptReason = reason;
54
56
  if (active.timeoutTimer) clearTimeout(active.timeoutTimer);
55
57
  active.timeoutTimer = null;
@@ -57,7 +59,11 @@ export class PythonKernel {
57
59
  () => void this.#escalateInterruptedRun(active).catch(() => undefined),
58
60
  interruptEscalationMs,
59
61
  );
62
+ const stateRetained = new Promise<boolean>((resolve) => {
63
+ active.resolveStateRetained = resolve;
64
+ });
60
65
  transport.interrupt(reason);
66
+ return { stateRetained };
61
67
  }
62
68
 
63
69
  async reset(): Promise<void> {
@@ -187,13 +193,18 @@ export class PythonKernel {
187
193
  if (this.#transport !== transport) return;
188
194
  const pending = this.#pending.get(result.cellId);
189
195
  if (pending) this.#settleRun(pending, result);
196
+ // A result frame from the live runner proves the process survived the interrupt.
197
+ if (pending?.resolveStateRetained) pending.resolveStateRetained(true);
190
198
  }
191
199
 
192
200
  #onExit(transport: PythonKernelTransport, error: Error): void {
193
201
  if (this.#transport !== transport) return;
194
202
  this.#transport = null;
195
203
  const active = this.#active;
196
- if (active) this.#settleRun(active, failedPythonResult(active.input.cellId, "Python kernel died", error.message));
204
+ if (active) {
205
+ if (active.resolveStateRetained) active.resolveStateRetained(false);
206
+ this.#settleRun(active, failedPythonResult(active.input.cellId, "Python kernel died", error.message));
207
+ }
197
208
  this.#startNext();
198
209
  }
199
210
 
@@ -209,7 +220,10 @@ export class PythonKernel {
209
220
  },
210
221
  );
211
222
  const active = this.#active;
212
- if (active) this.#settleRun(active, failedPythonResult(active.input.cellId, "Python kernel died", error.message));
223
+ if (active) {
224
+ if (active.resolveStateRetained) active.resolveStateRetained(false);
225
+ this.#settleRun(active, failedPythonResult(active.input.cellId, "Python kernel died", error.message));
226
+ }
213
227
  }
214
228
 
215
229
  #settleRun(pending: PendingRun, result: ResultMessage): void {
@@ -258,6 +272,7 @@ export class PythonKernel {
258
272
  async #escalateInterruptedRun(pending: PendingRun): Promise<void> {
259
273
  if (this.#active !== pending || pending.interruptReason === undefined) return;
260
274
  const transport = this.#transport;
275
+ if (pending.resolveStateRetained) pending.resolveStateRetained(false);
261
276
  if (transport) await this.#beginRetirement(transport);
262
277
  if (this.#pending.has(pending.input.cellId))
263
278
  this.#settleRun(pending, failedPythonResult(pending.input.cellId, "Eval interrupted"));
@@ -12,6 +12,7 @@ import json
12
12
  import locale
13
13
  import os
14
14
  import re
15
+ import signal
15
16
  import subprocess
16
17
  import sys
17
18
  import time
@@ -886,6 +887,9 @@ def run_cell(cell_id: str, code: str) -> None:
886
887
  start = time.monotonic()
887
888
  stdout = io.StringIO()
888
889
  stderr = io.StringIO()
890
+ # SIGINT must interrupt user code here; the idle baseline (set between
891
+ # cells) ignores it so a late signal cannot kill the stdin-read loop.
892
+ signal.signal(signal.SIGINT, signal.default_int_handler)
889
893
  try:
890
894
  with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
891
895
  body, expression = compile_cell(code)
@@ -914,6 +918,8 @@ def run_cell(cell_id: str, code: str) -> None:
914
918
  "durationMs": elapsed(start),
915
919
  }
916
920
  )
921
+ finally:
922
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
917
923
 
918
924
 
919
925
  def elapsed(start: float) -> int:
@@ -942,6 +948,7 @@ def handle(message: dict[str, Any]) -> bool:
942
948
 
943
949
 
944
950
  def main() -> None:
951
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
945
952
  for raw in sys.stdin:
946
953
  try:
947
954
  if not handle(json.loads(raw)):
@@ -1,5 +1,6 @@
1
1
  import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
2
2
  import { decodeBridgeFrame, encodeBridgeFrame, isKernelToHostMessage } from "../../bridge/protocol.ts";
3
+ import type { KernelInterruptHandle } from "../../tool/types.ts";
3
4
  import type { KernelResult, KernelRunInput, SubprocessKernelOptions, ToolCallMessage } from "./subprocess-contract.ts";
4
5
  import { type SubprocessLike, SubprocessProcess, type SubprocessSpawn, spawnSubprocess } from "./subprocess-process.ts";
5
6
  import { SubprocessRunQueue } from "./subprocess-queue.ts";
@@ -44,24 +45,26 @@ export class SubprocessKernel {
44
45
  return run;
45
46
  }
46
47
 
47
- async interrupt(reason = "interrupted"): Promise<void> {
48
- if (this.closed) return;
48
+ async interrupt(reason = "interrupted"): Promise<KernelInterruptHandle> {
49
+ if (this.closed) return { stateRetained: Promise.resolve(true) };
49
50
  if (!this.runs.active) {
50
- if (!this.retirementPromise) return;
51
+ if (!this.retirementPromise) return { stateRetained: Promise.resolve(true) };
51
52
  const queued = this.runs.takeWaiting();
52
53
  if (queued) this.runs.settle(queued, failureResult(queued, new CellInterruptedError(reason)));
53
- return;
54
+ return { stateRetained: Promise.resolve(true) };
54
55
  }
55
56
  const process = this.process;
56
57
  process?.retire();
57
58
  this.runs.clearToolCalls();
58
59
  const run = this.runs.active;
59
- if (!run) return;
60
+ if (!run) return { stateRetained: Promise.resolve(true) };
60
61
  this.runs.releaseActive(run);
61
62
  this.runs.settle(run, failureResult(run, new CellInterruptedError(reason)));
62
63
  const signal = globalThis.process.platform === "win32" ? "SIGTERM" : "SIGINT";
63
64
  await this.restartProcess(process, signal, 5_000);
64
65
  if (this.failure) throw this.failure;
66
+ // Restart always spawns a fresh interpreter, so no user global survives.
67
+ return { stateRetained: Promise.resolve(false) };
65
68
  }
66
69
 
67
70
  nextToolCall(): Promise<ToolCallMessage> {