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

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/src/index.ts CHANGED
@@ -1,10 +1,10 @@
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";
4
+ import type { EvalSchemaToolInfo } from "./bridges/schema-bridge.ts";
6
5
  import { type CompletionRequest, type CompletionResult, createCompletionHandler } from "./completion/handler.ts";
7
6
  import { defaultCodemodeSettings } from "./config/settings.ts";
7
+ import { EvalNotifier } from "./extension/eval-notifier.ts";
8
8
  import {
9
9
  createExecuteTool,
10
10
  createRuntime,
@@ -13,6 +13,7 @@ import {
13
13
  } from "./extension/runtime-factory.ts";
14
14
  import type { CodemodeSessionManager, CreateCodemodeSessionManagerOptions } from "./extension/session-manager.ts";
15
15
  import { SessionManagerProxy } from "./extension/session-manager-proxy.ts";
16
+ import { EvalDetachedCellManager } from "./tool/detached-cell-manager.ts";
16
17
  import { createEvalTool } from "./tool/eval-tool.ts";
17
18
  import { renderEvalCall, renderEvalResult } from "./tool/render.ts";
18
19
 
@@ -29,11 +30,12 @@ type CodemodeEvent = SessionLifecycleEvent | "model_select";
29
30
 
30
31
  export interface CodemodeExtensionAPI {
31
32
  registerTool(tool: ReturnType<typeof createEvalTool>): void;
33
+ registerRemovedToolHint(name: string, hint: string): void;
32
34
  on(event: CodemodeEvent, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void): void;
33
35
  executeTool: AgentExecuteTool;
34
36
  getActiveTools(): string[];
35
- setActiveTools?(toolNames: string[]): void | Promise<void>;
36
- getAllTools?(): readonly { readonly name: string }[];
37
+ getAllTools(): readonly EvalSchemaToolInfo[];
38
+ sendUserMessage(content: string, options?: { deliverAs?: "steer" | "followUp" }): void;
37
39
  }
38
40
 
39
41
  export interface SenpiCodemodeOptions {
@@ -43,28 +45,35 @@ export interface SenpiCodemodeOptions {
43
45
  readonly complete?: (request: CompletionRequest, ctx: ExtensionContext) => Promise<CompletionResult>;
44
46
  }
45
47
 
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
48
  export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCodemodeOptions = {}): void {
53
49
  const manager = new SessionManagerProxy();
54
50
  const complete = options.complete ?? ((request, ctx) => createCompletionHandler()(ctx)(request));
55
51
  const renderers = { renderCall: renderEvalCall, renderResult: renderEvalResult };
56
- let activeRuntime: (SessionRuntime & { readonly codeMode?: CodeModeSessionRuntime }) | undefined;
52
+ let activeRuntime: SessionRuntime | undefined;
57
53
  let activeModelId: string | undefined;
58
- const registerEvalForRuntime = (runtime: SessionRuntime, modelId: string | undefined): void => {
54
+ let activeContext: ExtensionContext | undefined;
55
+ let activeCells: EvalDetachedCellManager | undefined;
56
+ const notifier = new EvalNotifier({
57
+ sendUserMessage: (content, notifyOptions) => pi.sendUserMessage(content, notifyOptions),
58
+ getContext: () => activeContext,
59
+ getMode: () => "wake",
60
+ });
61
+ const registerEvalForRuntime = (
62
+ runtime: SessionRuntime,
63
+ modelId: string | undefined,
64
+ cellManager: EvalDetachedCellManager,
65
+ ): void => {
59
66
  pi.registerTool(
60
67
  createEvalTool({
61
68
  enabledLanguages: runtime.enabledLanguages,
62
69
  kernelManager: manager,
63
70
  cellTimeoutSeconds: runtime.settings.cellTimeoutSeconds,
64
71
  executeTool: runtime.executeTool,
72
+ listTools: () => pi.getAllTools(),
65
73
  complete,
66
74
  settings: runtime.settings,
67
75
  artifactsDir: runtime.artifactsDir,
76
+ cellManager,
68
77
  executionTracker: manager,
69
78
  renderers,
70
79
  spawns: runtime.spawns,
@@ -75,21 +84,13 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
75
84
  );
76
85
  };
77
86
  const dropRuntime = async (): Promise<void> => {
78
- const codeMode = activeRuntime?.codeMode;
87
+ const cells = activeCells;
79
88
  activeRuntime = undefined;
80
89
  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"));
90
+ activeCells = undefined;
91
+ await cells?.dispose();
92
+ activeContext = undefined;
93
+ await manager.dispose();
93
94
  };
94
95
  pi.registerTool(
95
96
  createEvalTool({
@@ -97,73 +98,56 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
97
98
  kernelManager: manager,
98
99
  cellTimeoutSeconds: defaultCodemodeSettings.cellTimeoutSeconds,
99
100
  executeTool: createExecuteTool(pi),
101
+ listTools: () => pi.getAllTools(),
100
102
  complete,
101
103
  settings: defaultCodemodeSettings,
104
+ cellManager: new EvalDetachedCellManager({ notifier }),
102
105
  executionTracker: manager,
103
106
  renderers,
104
107
  hostLine: hostLine(),
105
108
  }),
106
109
  );
110
+ pi.registerRemovedToolHint(
111
+ "exec",
112
+ 'exec was removed; use eval({ language: "js", code }) instead. Long eval cells detach on timeout and notify when complete.',
113
+ );
114
+ pi.registerRemovedToolHint(
115
+ "wait",
116
+ 'wait was removed; detached eval cells notify when complete. Use eval({ action: "peek"|"stop", cell_id }) to inspect or stop one.',
117
+ );
107
118
 
108
119
  pi.on("session_start", async (event, ctx) => {
109
- const previousCodeMode = activeRuntime?.codeMode;
120
+ const previousCells = activeCells;
121
+ activeCells = undefined;
122
+ await previousCells?.dispose();
110
123
  const generation = manager.beginReplacement();
111
124
  const runtime = await createRuntime(pi, ctx, event, complete, options);
112
125
  const replaced = await manager.replace(generation, runtime.manager);
113
126
  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 }) };
127
+ notifier.reset();
128
+ activeContext = ctx;
129
+ const cellManager = new EvalDetachedCellManager({ artifactsDir: runtime.artifactsDir, notifier });
130
+ activeCells = cellManager;
131
+ activeRuntime = runtime;
124
132
  activeModelId = ctx.model?.id;
125
- registerEvalForRuntime(activeRuntime, activeModelId);
126
- if (codeMode) await activateCodeModeTools(codeMode);
127
- else await deactivateCodeModeTools();
133
+ registerEvalForRuntime(runtime, activeModelId, cellManager);
128
134
  });
129
135
  pi.on("session_shutdown", async () => dropRuntime());
130
136
  pi.on("session_before_switch", async () => dropRuntime());
131
137
  pi.on("session_before_fork", async () => dropRuntime());
132
- pi.on("model_select", async (event) => {
138
+ pi.on("model_select", async (event, ctx) => {
139
+ activeContext = ctx;
133
140
  const runtime = activeRuntime;
134
141
  if (runtime === undefined) return;
135
142
  const modelId = modelIdFrom(event);
136
143
  if (modelId === undefined || modelId === activeModelId) return;
137
144
  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);
145
+ const cellManager = activeCells;
146
+ if (cellManager === undefined) return;
147
+ registerEvalForRuntime(runtime, modelId, cellManager);
160
148
  });
161
149
  }
162
150
 
163
- function isDynamicCodeModeExtensionAPI(pi: CodemodeExtensionAPI): pi is DynamicCodeModeExtensionAPI {
164
- return typeof pi.setActiveTools === "function" && typeof pi.getAllTools === "function";
165
- }
166
-
167
151
  function hostLine(): string {
168
152
  const cpu = os.cpus()[0]?.model?.trim();
169
153
  return [`${os.platform()} ${os.arch()}`, cpu, `${os.availableParallelism()} cores`]
@@ -194,6 +194,12 @@ function completion(prompt::AbstractString; model="default", system=nothing, sch
194
194
  get(response, "text", response)
195
195
  end
196
196
 
197
+ function tool_schema(name=nothing)
198
+ arguments = Dict{String, Any}()
199
+ name !== nothing && (arguments["name"] = string(name))
200
+ senpi_with_bridge_timeout_pause(() -> senpi_call_tool("__schema__", arguments))
201
+ end
202
+
197
203
  function output(ids...; format="raw", offset=nothing, limit=nothing)
198
204
  isempty(ids) && error("At least one output ID is required")
199
205
  format in ("raw", "tail") || error("output() format must be 'raw' or 'tail'")
@@ -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> {
@@ -4,6 +4,7 @@ import type { BridgeConnectionConfig } from "../../bridge/protocol.ts";
4
4
  import {
5
5
  RESERVED_AGENT_TOOL,
6
6
  RESERVED_OUTPUT_TOOL,
7
+ RESERVED_SCHEMA_TOOL,
7
8
  TIMEOUT_PAUSE_OP,
8
9
  TIMEOUT_RESUME_OP,
9
10
  } from "../../bridge/reserved.ts";
@@ -33,6 +34,7 @@ type RuntimeModuleContext = {
33
34
  readonly cwdUrl: string;
34
35
  readonly localRootUrls: Readonly<Record<string, string>>;
35
36
  readonly reservedAgentTool: string;
37
+ readonly reservedSchemaTool: string;
36
38
  readonly reservedOutputTool: string;
37
39
  readonly timeoutPauseOp: string;
38
40
  readonly timeoutResumeOp: string;
@@ -55,6 +57,7 @@ function runtimeContext(options: LocalModuleLoaderOptions): RuntimeModuleContext
55
57
  localRootUrls: roots,
56
58
  reservedAgentTool: RESERVED_AGENT_TOOL,
57
59
  reservedOutputTool: RESERVED_OUTPUT_TOOL,
60
+ reservedSchemaTool: RESERVED_SCHEMA_TOOL,
58
61
  timeoutPauseOp: TIMEOUT_PAUSE_OP,
59
62
  timeoutResumeOp: TIMEOUT_RESUME_OP,
60
63
  };
@@ -66,6 +69,7 @@ function loaderPrelude(context: RuntimeModuleContext): string {
66
69
  `globalThis.__senpi_module_context__ = ${serialized};`,
67
70
  "globalThis.__senpi_reserved_agent_tool__ = globalThis.__senpi_module_context__.reservedAgentTool;",
68
71
  "globalThis.__senpi_reserved_output_tool__ = globalThis.__senpi_module_context__.reservedOutputTool;",
72
+ "globalThis.__senpi_reserved_schema_tool__ = globalThis.__senpi_module_context__.reservedSchemaTool;",
69
73
  "globalThis.__senpi_timeout_pause_op__ = globalThis.__senpi_module_context__.timeoutPauseOp;",
70
74
  "globalThis.__senpi_timeout_resume_op__ = globalThis.__senpi_module_context__.timeoutResumeOp;",
71
75
  "globalThis.__senpi_import__ = async (source, options) => {",
@@ -50,6 +50,7 @@ export class JsWorkerRuntime {
50
50
  globalThis.read = async (path, options, ...rest) => await this.#read(path, helperOptions("read", options, rest));
51
51
  globalThis.write = async (path, content) => await this.#write(path, content);
52
52
  globalThis.output = async (...args) => await this.#output(args);
53
+ globalThis.tool_schema = async name => await this.#toolSchema(name);
53
54
  globalThis.agent = async (prompt, options, ...rest) => await this.#agent(prompt, options, rest);
54
55
  globalThis.parallel = async thunks => await this.#parallel(thunks);
55
56
  globalThis.pipeline = async (items, ...stages) => await this.#pipeline(items, stages);
@@ -250,6 +251,11 @@ export class JsWorkerRuntime {
250
251
  return node;
251
252
  }
252
253
 
254
+ async #toolSchema(name) {
255
+ const args = name === undefined || name === null ? {} : { name: String(name) };
256
+ return await this.#callTool(reservedTool("__senpi_reserved_schema_tool__", "tool_schema"), args);
257
+ }
258
+
253
259
  async #callTool(toolName, args) {
254
260
  const hooks = this.#hooks;
255
261
  if (!hooks) throw new Error("tool call outside active JS cell");
@@ -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
@@ -36,6 +37,7 @@ EMIT_LOCK = Lock()
36
37
  # Mirrors src/bridge/reserved.ts; this standalone subprocess asset cannot import TypeScript.
37
38
  RESERVED_AGENT_TOOL = "__agent__"
38
39
  RESERVED_OUTPUT_TOOL = "__output__"
40
+ RESERVED_SCHEMA_TOOL = "__schema__"
39
41
  TIMEOUT_PAUSE_OP = "timeout-pause"
40
42
  TIMEOUT_RESUME_OP = "timeout-resume"
41
43
 
@@ -373,6 +375,14 @@ def completion(
373
375
  return response.get("text", response)
374
376
 
375
377
 
378
+ def tool_schema(name: str | None = None) -> Any:
379
+ args: dict[str, Any] = {} if name is None else {"name": name}
380
+ return bridge_post(
381
+ "/call",
382
+ {"callId": f"py-{uuid.uuid4()}", "toolName": RESERVED_SCHEMA_TOOL, "args": args},
383
+ )
384
+
385
+
376
386
  def output(
377
387
  *ids: str,
378
388
  format: str = "raw",
@@ -843,6 +853,7 @@ USER_NS.update(
843
853
  "completion": completion,
844
854
  "agent": agent,
845
855
  "output": output,
856
+ "tool_schema": tool_schema,
846
857
  "__senpi_magic": _magic,
847
858
  "__senpi_magic_cell": _magic_cell,
848
859
  "__senpi_shell": _shell,
@@ -886,6 +897,9 @@ def run_cell(cell_id: str, code: str) -> None:
886
897
  start = time.monotonic()
887
898
  stdout = io.StringIO()
888
899
  stderr = io.StringIO()
900
+ # SIGINT must interrupt user code here; the idle baseline (set between
901
+ # cells) ignores it so a late signal cannot kill the stdin-read loop.
902
+ signal.signal(signal.SIGINT, signal.default_int_handler)
889
903
  try:
890
904
  with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
891
905
  body, expression = compile_cell(code)
@@ -914,6 +928,8 @@ def run_cell(cell_id: str, code: str) -> None:
914
928
  "durationMs": elapsed(start),
915
929
  }
916
930
  )
931
+ finally:
932
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
917
933
 
918
934
 
919
935
  def elapsed(start: float) -> int:
@@ -942,6 +958,7 @@ def handle(message: dict[str, Any]) -> bool:
942
958
 
943
959
 
944
960
  def main() -> None:
961
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
945
962
  for raw in sys.stdin:
946
963
  try:
947
964
  if not handle(json.loads(raw)):
@@ -5,6 +5,7 @@ require "uri"
5
5
 
6
6
  SENPI_RESERVED_AGENT_TOOL = "__agent__"
7
7
  SENPI_RESERVED_OUTPUT_TOOL = "__output__"
8
+ SENPI_RESERVED_SCHEMA_TOOL = "__schema__"
8
9
  SENPI_INTERNAL_URL = Regexp.new("\\A([a-z][a-z0-9+.\\-]*)://(.*)\\z", Regexp::IGNORECASE)
9
10
 
10
11
  def __senpi_status_enabled?
@@ -187,6 +188,11 @@ def completion(prompt, model: "default", system: nil, schema: nil, **kwargs)
187
188
  result.fetch("text", result)
188
189
  end
189
190
 
191
+ def tool_schema(name = nil)
192
+ args = name.nil? ? {} : { "name" => name.to_s }
193
+ __senpi_call_tool(SENPI_RESERVED_SCHEMA_TOOL, args)
194
+ end
195
+
190
196
  def output(*ids, format: "raw", offset: nil, limit: nil)
191
197
  raise ArgumentError, "At least one output ID is required" if ids.empty?
192
198
  raise ArgumentError, "output() format must be 'raw' or 'tail'" unless ["raw", "tail"].include?(format)
@@ -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> {
@@ -21,7 +21,7 @@ export interface EvalPromptOptions {
21
21
  }
22
22
 
23
23
  /** Prompt dialect for the eval-first batching emphasis. */
24
- export type EvalEmphasisStyle = "default" | "claude" | "codex" | "kimi";
24
+ export type EvalEmphasisStyle = "default" | "claude" | "codex" | "gpt" | "kimi";
25
25
 
26
26
  const CLAUDE_MODEL_RE = /(^|[/.:])claude[-.]/i;
27
27
  const GLM_MODEL_RE = /(^|[/.:@-])glm[-.]?\d/i;
@@ -32,14 +32,22 @@ const OPENAI_MODEL_RE = /(^|[/.:])(gpt|chatgpt|codex)[-.]|(^|[/.:])o[134](?:[-.]
32
32
  * Selects the eval-first batching dialect for a model id:
33
33
  * - `claude`: Claude/GLM — direct imperatives; both are steered most reliably
34
34
  * by explicit tagged directives (GLM prompting guidance routes to Claude's).
35
- * - `codex`: OpenAI reasoning families — terse bounded rules, no emphasis spam.
35
+ * - `gpt`: GPT models — terse composition-forward rules that direct detached
36
+ * cells to notify on completion instead of being polled.
37
+ * - `codex`: Other OpenAI reasoning families — terse bounded rules, no emphasis spam.
36
38
  * - `kimi`: Kimi K-series — maximum-emphasis POSITIVE imperatives (uppercase/
37
39
  * bold DO-framing); all-caps NEVER prohibitions stay out because they make
38
40
  * K-series overthink instead of comply.
39
41
  * - `default`: everything else (and no model) — maximum-emphasis fallback.
40
42
  */
43
+ /** True only for GPT model ids that receive the terse eval composition dialect. */
44
+ export function isGptCodeModeModel(modelId: string | undefined): boolean {
45
+ return modelId !== undefined && /(^|[/.:])gpt[-.]/iu.test(modelId);
46
+ }
47
+
41
48
  export function evalEmphasisStyle(modelId: string | undefined): EvalEmphasisStyle {
42
49
  if (!modelId) return "default";
50
+ if (isGptCodeModeModel(modelId)) return "gpt";
43
51
  if (CLAUDE_MODEL_RE.test(modelId) || GLM_MODEL_RE.test(modelId)) return "claude";
44
52
  if (KIMI_MODEL_RE.test(modelId)) return "kimi";
45
53
  if (OPENAI_MODEL_RE.test(modelId)) return "codex";
@@ -92,7 +100,11 @@ Work incrementally: imports in one call, define in the next, test, then use —
92
100
  - 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.
93
101
  - 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.
94
102
  - Post-process \`tool.<name>()\` results programmatically and return distilled facts, not raw dumps.
95
- </eval_first_batching>{{/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.
103
+ </eval_first_batching>{{/if}}{{#if styleGpt}}<gpt_eval_dialect>
104
+ 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.
105
+ - Long pure-compute cells detach on timeout and notify on completion. Do not poll or re-run them; use \`eval({ action: "peek"|"stop", cell_id })\` only to inspect or stop a detached cell.
106
+ - Reduce tool results in the cell and return only decision-relevant facts.
107
+ </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.
96
108
  - Loop or comprehend over file sets with \`read()\`/stdlib instead of reading files one call at a time; post-process \`tool.<name>()\` results programmatically.
97
109
  - 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.
98
110
  - Reduce large results in-kernel to the facts the task needs before returning.{{/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.
@@ -112,13 +124,17 @@ Fields:
112
124
  - \`code\` — cell body, verbatim. Newlines/quotes JSON-encoded; no fences, no headers.
113
125
  - \`title\` (optional) — short transcript label (e.g. \`"imports"\`).
114
126
  - \`timeout\` (optional) — seconds. Raise only for heavy compute or long{{#if spawns}} non-agent{{/if}} tool calls.
127
+ - \`on_timeout\` (optional) — \`"detach"\` keeps pure computation running in interactive sessions (the default); \`"error"\` interrupts for deadline-sensitive work and is the print/json default.
115
128
  - \`reset\` (optional) — wipe this language's kernel first.{{#ifAll py js}} Per-language: a \`py\` reset never touches the JS VM.{{/ifAll}}
129
+ - \`action\` (optional) — defaults to \`"run"\`. A detached cell returns its id: use \`eval({ action: "peek", cell_id })\` for buffered output/state or \`eval({ action: "stop", cell_id })\` to cancel it.
130
+
131
+ A detached cell keeps its language kernel busy while it finishes. Do not re-run a detached cell: the same-language busy error names its cell id and output tail; another language can continue. Completion arrives as one notification with the final value/error and buffered output. Stopping a cell interrupts its kernel; the stop result states whether kernel state survived or the kernel was restarted and its variables lost.
116
132
 
117
133
  {{#if py}}Live event loop: use top-level \`await\` directly; \`asyncio.run(…)\` raises "cannot be called from a running event loop".{{/if}}
118
134
  {{#if js}}JS runs under Node.js worker: top-level \`await\`/\`return\` work; \`fetch\`/\`Buffer\` available.{{/if}}
119
135
  {{#if rb}}Ruby: synchronous; helper options are keyword args{{#if spawns}} (e.g. \`output("id", limit: 2)\`){{/if}}; the last expression auto-displays unless it is \`nil\`, an assignment, or a definition (like IRB).{{/if}}
120
136
  {{#if jl}}Julia: synchronous; helper options are standard keyword args{{#if spawns}} (e.g. \`output("id", limit=2)\`){{/if}}; the last expression auto-displays unless it is an assignment or a definition (like the Julia REPL).{{/if}}
121
- On error, fix and re-run only the failing step — prior calls' state survives.
137
+ On error, fix and re-run only the failing step. State usually survives a normal error, but a timeout or stop may have restarted the kernel its message says which. Before rebuilding state, check a sentinel (a variable you defined earlier); only re-establish what is actually gone, since blind re-runs duplicate side effects.
122
138
  </instruction>
123
139
 
124
140
  <prelude>
@@ -135,9 +151,14 @@ write(path, content) → str
135
151
  env(key?=None, value?=None) → str | None | dict
136
152
  No args → full env dict; one → value of \`key\`; two → set \`key=value\`, return value.
137
153
  {{#if spawns}}output(*ids, format?="raw", offset?=None, limit?=None) → str | dict | list[dict]
138
- Task/agent output by id. \`format\` selects full (\`"raw"\`) or trailing (\`"tail"\`) output.
154
+ Task/agent output by id. Reads immediately: running tasks return their status; \`format\` selects full (\`"raw"\`) or trailing (\`"tail"\`) output.
139
155
  {{/if}}tool.<name>(args) → unknown
140
156
  Invoke any session tool; \`args\` = its parameter object.
157
+ tool_schema(name?) → dict
158
+ Parameter schema of a tool without calling it; omit \`name\` to list tool names.
159
+ Use it before calling a tool you have not called before — a failed call also
160
+ returns the expected parameters, so fix the args and retry in the next cell
161
+ instead of abandoning eval.
141
162
  completion(prompt, model?="default", system?=None, schema?=None) → str | dict
142
163
  Oneshot, stateless (no history/tools). \`model\`: \`"smol"\` fast | \`"default"\` session | \`"slow"\` most capable. \`schema\` (JSON-Schema) → structured output, parsed object.
143
164
  {{#if spawns}}agent(prompt, agent?="{{spawnDefaultAgent}}", model?=None, label?=None, schema?=None, handle?=False) → str | dict
@@ -186,6 +207,7 @@ export function buildEvalPrompt(
186
207
  spawnDefaultAgent,
187
208
  styleClaude: style === "claude",
188
209
  styleCodex: style === "codex",
210
+ styleGpt: style === "gpt",
189
211
  styleKimi: style === "kimi",
190
212
  styleDefault: style === "default",
191
213
  hostLine: options.hostLine ?? "",
@@ -225,6 +247,7 @@ const BATCHING_GUIDELINES: Record<EvalEmphasisStyle, string> = {
225
247
  claude:
226
248
  "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.",
227
249
  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.",
250
+ gpt: "Use eval to compose tool work in one cell; long cells detach on timeout and notify on completion, so do not poll.",
228
251
  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.",
229
252
  };
230
253