@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.
@@ -0,0 +1,272 @@
1
+ import type { HostToKernelMessage } from "../bridge/protocol.ts";
2
+ import { kernelToolError } from "../kernels/js/kernel-tools-errors.ts";
3
+ import { restartNotice, unstartedResult } from "../kernels/shared/kernel-death.ts";
4
+ import type {
5
+ EvalKernel,
6
+ EvalKernelResult,
7
+ EvalKernelRunInput,
8
+ EvalLanguage,
9
+ KernelInterruptHandle,
10
+ } from "../tool/types.ts";
11
+ import { hasKernelTools, type KernelToolsMethods } from "./kernel-tools-probe.ts";
12
+
13
+ export type RestartParkedKernel = () => Promise<EvalKernel>;
14
+
15
+ class IdleParkingKernelClosedError extends Error {
16
+ readonly name = "IdleParkingKernelClosedError";
17
+
18
+ constructor() {
19
+ super("Kernel closed");
20
+ }
21
+ }
22
+
23
+ interface WaitingCell {
24
+ readonly cancel: (reason: string) => void;
25
+ }
26
+
27
+ /**
28
+ * Opt-in (`memory.idleParkMinutes` > 0): once no cell has been running or queued on the session's kernel for
29
+ * the configured time, closes it to give its memory back, and starts a fresh one when the next cell arrives.
30
+ * That cell's result says the kernel was restarted and every global is gone. A cell submitted while the
31
+ * kernel is being parked or restarted waits for the fresh kernel; a restart that fails settles that cell
32
+ * with the reason, and the next cell tries again.
33
+ */
34
+ export class IdleParkingKernel implements EvalKernel {
35
+ readonly #language: EvalLanguage;
36
+ readonly #idleMs: number;
37
+ readonly #restart: RestartParkedKernel;
38
+ readonly #onParked: () => void;
39
+ #kernel: EvalKernel | null;
40
+ #inFlight = 0;
41
+ #timer: ReturnType<typeof setTimeout> | undefined;
42
+ #parking: Promise<void> | null = null;
43
+ #parkFailure: string | null = null;
44
+ #starting: Promise<EvalKernel> | null = null;
45
+ #announce: string | null = null;
46
+ readonly #waiting = new Map<string, WaitingCell>();
47
+ #closed = false;
48
+ // Present only when the wrapped kernel has kernel tools, so a probe for them sees what it saw before.
49
+ declare readonly describeKernelTools?: KernelToolsMethods["describeKernelTools"];
50
+ declare readonly invokeKernelTool?: KernelToolsMethods["invokeKernelTool"];
51
+
52
+ constructor(
53
+ language: EvalLanguage,
54
+ idleMinutes: number,
55
+ first: EvalKernel,
56
+ restart: RestartParkedKernel,
57
+ onParked: () => void = () => {},
58
+ ) {
59
+ this.#language = language;
60
+ this.#idleMs = idleMinutes * 60_000;
61
+ this.#restart = restart;
62
+ this.#onParked = onParked;
63
+ this.#kernel = first;
64
+ if (hasKernelTools(first)) {
65
+ const tools: KernelToolsMethods = {
66
+ describeKernelTools: (names) => this.#withTools((kernel) => kernel.describeKernelTools(names)),
67
+ invokeKernelTool: (request, options) =>
68
+ this.#withTools((kernel) => kernel.invokeKernelTool(request, options)),
69
+ };
70
+ Object.assign(this, tools);
71
+ }
72
+ this.#arm();
73
+ }
74
+
75
+ run(input: EvalKernelRunInput): Promise<EvalKernelResult> {
76
+ if (this.#closed) return Promise.reject(new IdleParkingKernelClosedError());
77
+ this.#disarm();
78
+ this.#inFlight++;
79
+ return this.#runOnCurrent(input).finally(() => {
80
+ this.#inFlight--;
81
+ this.#arm();
82
+ });
83
+ }
84
+
85
+ cancelQueued(cellId: string, reason: string): boolean {
86
+ if (this.#cancelWaiting(cellId, reason)) return true;
87
+ return this.#kernel?.cancelQueued(cellId, reason) ?? false;
88
+ }
89
+
90
+ async interrupt(reason = "interrupted", cellId?: string): Promise<KernelInterruptHandle> {
91
+ if (cellId !== undefined && this.#cancelWaiting(cellId, reason)) return { stateRetained: Promise.resolve(true) };
92
+ if (cellId === undefined) for (const id of [...this.#waiting.keys()]) this.#cancelWaiting(id, reason);
93
+ const kernel = this.#kernel;
94
+ if (kernel === null) return { stateRetained: Promise.resolve(true) };
95
+ return await kernel.interrupt(reason, cellId);
96
+ }
97
+
98
+ queueSnapshot(): { activeCellId: string | null; queuedCellIds: readonly string[] } {
99
+ const current = this.#kernel?.queueSnapshot() ?? { activeCellId: null, queuedCellIds: [] };
100
+ return { activeCellId: current.activeCellId, queuedCellIds: [...current.queuedCellIds, ...this.#waiting.keys()] };
101
+ }
102
+
103
+ deliverToolReply(message: Extract<HostToKernelMessage, { type: "tool-reply" }>): void {
104
+ this.#kernel?.deliverToolReply(message);
105
+ }
106
+
107
+ async reset(): Promise<void> {
108
+ this.#announce = null;
109
+ const kernel = this.#kernel;
110
+ if (kernel !== null) await kernel.reset();
111
+ }
112
+
113
+ async close(): Promise<void> {
114
+ this.#closed = true;
115
+ this.#disarm();
116
+ for (const id of [...this.#waiting.keys()]) this.#cancelWaiting(id, new IdleParkingKernelClosedError().message);
117
+ if (this.#parking !== null) await this.#parking;
118
+ if (this.#starting !== null) await this.#starting.catch(() => undefined);
119
+ const kernel = this.#kernel;
120
+ this.#kernel = null;
121
+ if (kernel !== null) await kernel.close();
122
+ }
123
+
124
+ listKernelToolNames(): readonly string[] {
125
+ return this.#kernel?.listKernelToolNames?.() ?? [];
126
+ }
127
+
128
+ /** A tool call is work on the kernel: it keeps the kernel from being parked under it. */
129
+ async #withTools<T>(use: (kernel: KernelToolsMethods) => Promise<T>): Promise<T> {
130
+ const kernel = this.#parking === null ? this.#kernel : null;
131
+ if (this.#closed || kernel === null || !hasKernelTools(kernel)) {
132
+ throw kernelToolError(
133
+ "tools_unavailable",
134
+ `the ${this.#language} kernel was parked after idling, so the tools it defined are gone; run a cell to start a fresh kernel`,
135
+ );
136
+ }
137
+ this.#disarm();
138
+ this.#inFlight++;
139
+ try {
140
+ return await use(kernel);
141
+ } finally {
142
+ this.#inFlight--;
143
+ this.#arm();
144
+ }
145
+ }
146
+
147
+ #runOnCurrent(input: EvalKernelRunInput): Promise<EvalKernelResult> {
148
+ const kernel = this.#kernel;
149
+ if (kernel !== null && this.#parking === null) return kernel.run(input).then((result) => this.#announced(result));
150
+ return new Promise((settle, reject) => {
151
+ let cancelled = false;
152
+ this.#waiting.set(input.cellId, {
153
+ cancel: (reason) => {
154
+ cancelled = true;
155
+ settle(unstartedResult(input.cellId, reason));
156
+ },
157
+ });
158
+ this.#current().then(
159
+ (current) => {
160
+ this.#waiting.delete(input.cellId);
161
+ if (cancelled) return;
162
+ current.run(input).then((result) => settle(this.#announced(result)), reject);
163
+ },
164
+ (error: unknown) => {
165
+ this.#waiting.delete(input.cellId);
166
+ if (!cancelled) settle(unstartedResult(input.cellId, this.#restartFailure(error)));
167
+ },
168
+ );
169
+ });
170
+ }
171
+
172
+ async #current(): Promise<EvalKernel> {
173
+ if (this.#parking !== null) await this.#parking;
174
+ const failure = this.#parkFailure;
175
+ if (failure !== null) {
176
+ this.#parkFailure = null;
177
+ throw new Error(failure);
178
+ }
179
+ if (this.#kernel !== null) return this.#kernel;
180
+ if (this.#closed) throw new IdleParkingKernelClosedError();
181
+ this.#starting ??= this.#restart()
182
+ .then((kernel) => {
183
+ this.#kernel = kernel;
184
+ this.#announce = restartNotice(this.#language, this.#parkReason());
185
+ return kernel;
186
+ })
187
+ .finally(() => {
188
+ this.#starting = null;
189
+ });
190
+ return await this.#starting;
191
+ }
192
+
193
+ #announced(result: EvalKernelResult): EvalKernelResult {
194
+ const notice = this.#announce;
195
+ if (notice === null) return result;
196
+ this.#announce = null;
197
+ return {
198
+ ...result,
199
+ notice: result.notice === undefined ? notice : `${notice}\n${result.notice}`,
200
+ kernelState: "restarted",
201
+ };
202
+ }
203
+
204
+ #arm(): void {
205
+ if (this.#closed || this.#inFlight > 0 || this.#kernel === null || this.#timer !== undefined) return;
206
+ this.#timer = setTimeout(() => {
207
+ this.#timer = undefined;
208
+ this.#park();
209
+ }, this.#idleMs);
210
+ this.#timer.unref?.();
211
+ }
212
+
213
+ #disarm(): void {
214
+ if (this.#timer === undefined) return;
215
+ clearTimeout(this.#timer);
216
+ this.#timer = undefined;
217
+ }
218
+
219
+ #park(): void {
220
+ const kernel = this.#kernel;
221
+ if (this.#closed || this.#inFlight > 0 || kernel === null) return;
222
+ const snapshot = kernel.queueSnapshot();
223
+ // Tools a cell defined stay callable by child tasks between cells, so their kernel is never parked.
224
+ const hasTools = (kernel.listKernelToolNames?.() ?? []).length > 0;
225
+ if (snapshot.activeCellId !== null || snapshot.queuedCellIds.length > 0 || hasTools) {
226
+ this.#arm();
227
+ return;
228
+ }
229
+ this.#kernel = null;
230
+ this.#onParked();
231
+ const parking = kernel
232
+ .close()
233
+ .catch((error: unknown) => {
234
+ this.#parkFailure = `the idle ${this.#language} kernel could not be stopped: ${errorText(error)}; run the cell again to start a fresh kernel`;
235
+ })
236
+ .finally(() => {
237
+ if (this.#parking === parking) this.#parking = null;
238
+ });
239
+ this.#parking = parking;
240
+ }
241
+
242
+ #cancelWaiting(cellId: string, reason: string): boolean {
243
+ const waiting = this.#waiting.get(cellId);
244
+ if (waiting === undefined) return false;
245
+ this.#waiting.delete(cellId);
246
+ waiting.cancel(reason);
247
+ return true;
248
+ }
249
+
250
+ #parkReason(): string {
251
+ const minutes = this.#idleMs / 60_000;
252
+ return `idling for ${minutes} minute${minutes === 1 ? "" : "s"} (memory.idleParkMinutes)`;
253
+ }
254
+
255
+ #restartFailure(error: unknown): string {
256
+ return `the ${this.#language} kernel parked after idling did not restart: ${errorText(error)}; run the cell again to retry`;
257
+ }
258
+ }
259
+
260
+ export function parkWhenIdle(
261
+ language: EvalLanguage,
262
+ idleMinutes: number,
263
+ kernel: EvalKernel,
264
+ restart: RestartParkedKernel,
265
+ onParked?: () => void,
266
+ ): EvalKernel {
267
+ return idleMinutes > 0 ? new IdleParkingKernel(language, idleMinutes, kernel, restart, onParked) : kernel;
268
+ }
269
+
270
+ function errorText(error: unknown): string {
271
+ return error instanceof Error ? error.message : String(error);
272
+ }
@@ -35,6 +35,31 @@ export function registerKernel(sessionId: string, language: EvalLanguage, memory
35
35
  return id;
36
36
  }
37
37
 
38
+ /** One registry entry per language for a session; replacing a language's kernel replaces its entry. */
39
+ export class SessionKernelRegistrations {
40
+ readonly #owner: string;
41
+ readonly #ids = new Map<EvalLanguage, string>();
42
+
43
+ constructor(owner: string) {
44
+ this.#owner = owner;
45
+ }
46
+
47
+ register(language: EvalLanguage, memory: RegisteredKernelSource | undefined): void {
48
+ this.unregister(language);
49
+ if (memory !== undefined) this.#ids.set(language, registerKernel(this.#owner, language, memory));
50
+ }
51
+
52
+ unregister(language: EvalLanguage): void {
53
+ const id = this.#ids.get(language);
54
+ if (id !== undefined) kernelRegistry.unregister(id);
55
+ this.#ids.delete(language);
56
+ }
57
+
58
+ clear(): void {
59
+ for (const language of [...this.#ids.keys()]) this.unregister(language);
60
+ }
61
+ }
62
+
38
63
  export function javaScriptKernelMemory(kernel: JavaScriptKernel): RegisteredKernelSource {
39
64
  return {
40
65
  measure: "heap",
@@ -0,0 +1,24 @@
1
+ import type {
2
+ KernelToolsDescribeResult,
3
+ KernelToolsInvokeOptions,
4
+ KernelToolsInvokeRequest,
5
+ } from "../kernels/js/kernel-tools-types.ts";
6
+ import type { EvalKernel } from "../tool/types.ts";
7
+
8
+ /** The kernel-tools surface a JavaScript kernel exposes; `run-eval-cell` finds it by probing for these methods. */
9
+ export interface KernelToolsMethods {
10
+ describeKernelTools(names: readonly string[]): Promise<KernelToolsDescribeResult>;
11
+ invokeKernelTool(
12
+ request: KernelToolsInvokeRequest,
13
+ options?: AbortSignal | KernelToolsInvokeOptions,
14
+ ): Promise<unknown>;
15
+ }
16
+
17
+ export function hasKernelTools(kernel: EvalKernel): kernel is EvalKernel & KernelToolsMethods {
18
+ return (
19
+ "describeKernelTools" in kernel &&
20
+ typeof kernel.describeKernelTools === "function" &&
21
+ "invokeKernelTool" in kernel &&
22
+ typeof kernel.invokeKernelTool === "function"
23
+ );
24
+ }
@@ -2,22 +2,21 @@ import { join } from "node:path";
2
2
  import type { ExtensionContext } from "@code-yeongyu/senpi";
3
3
  import { type BridgeServerHandle, startBridgeServer } from "../bridge/http-server.ts";
4
4
  import type { KernelToHostMessage } from "../bridge/protocol.ts";
5
- import { isReservedToolName, runReservedTool } from "../bridges/reserved-dispatch.ts";
6
5
  import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
7
6
  import { resolveKernelMemoryThresholds } from "../config/memory-settings.ts";
8
- import { defaultCodemodeSettings } from "../config/settings.ts";
9
7
  import { collectOrphanedChildren } from "../host-sdk.ts";
10
8
  import { JavaScriptKernel } from "../kernels/js/context-manager.ts";
11
9
  import type { KernelLifecycle } from "../kernels/shared/kernel-death.ts";
12
- import { marshalToolResult } from "../tool/image.ts";
13
10
  import type { EvalKernel, EvalLanguage } from "../tool/types.ts";
11
+ import { type BridgeToolCallRequest, routeBridgeToolCall } from "./bridge-tool-call.ts";
12
+ import { parkWhenIdle } from "./idle-parking-kernel.ts";
14
13
  import {
15
14
  javaScriptKernelMemory,
16
- registerKernel,
15
+ SessionKernelRegistrations,
17
16
  type StartedKernel,
18
17
  startSubprocessKernel,
19
18
  } from "./kernel-registration.ts";
20
- import { kernelRegistry, type RegisteredKernelSource } from "./kernel-registry.ts";
19
+ import type { RegisteredKernelSource } from "./kernel-registry.ts";
21
20
  import { ReplaceableKernel } from "./kernel-replacement.ts";
22
21
  import { assertSessionCwdAvailable } from "./session-cwd.ts";
23
22
  import type {
@@ -61,7 +60,7 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
61
60
  readonly #options: CreateCodemodeSessionManagerOptions;
62
61
  #bridge: BridgeServerHandle | undefined;
63
62
  #kernels = new Map<EvalLanguage, EvalKernel>();
64
- readonly #registrations = new Map<EvalLanguage, string>();
63
+ readonly #registrations: SessionKernelRegistrations;
65
64
  #kernelCreations = new Map<EvalLanguage, Promise<EvalKernel>>();
66
65
  #onMessageRefs = new Map<EvalLanguage, (message: KernelToHostMessage) => void>();
67
66
  #context: ExtensionContext | undefined;
@@ -70,6 +69,7 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
70
69
 
71
70
  constructor(options: CreateCodemodeSessionManagerOptions) {
72
71
  this.#options = options;
72
+ this.#registrations = new SessionKernelRegistrations(options.ownerSessionId ?? options.sessionId);
73
73
  }
74
74
 
75
75
  async start(): Promise<void> {
@@ -81,29 +81,8 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
81
81
  });
82
82
  }
83
83
 
84
- // Subprocess kernels (py/rb/jl) reach the host only through this route, so every reply
85
- // must match the in-process JS path in tool/cell-handler.ts: reserved helper names dispatch
86
- // through runReservedTool (forwarding them made agent() fail with "Unknown tool __agent__"),
87
- // and ordinary tool results are marshalled to { text, images, details, hasError } — the raw
88
- // { content } shape left python cells unable to reach tool.read image blocks.
89
- async #call(request: { toolName: string; args: unknown; callId: string; signal: AbortSignal }): Promise<unknown> {
90
- if (!isReservedToolName(request.toolName)) {
91
- return marshalToolResult(
92
- await this.#options.executeTool(request.toolName, request.args, { signal: request.signal }),
93
- );
94
- }
95
- const taskTools = this.#options.settings.taskTools ?? defaultCodemodeSettings.taskTools;
96
- return await runReservedTool(request.toolName, {
97
- callId: request.callId,
98
- args: request.args,
99
- executeTool: this.#options.executeTool,
100
- taskToolName: taskTools.task,
101
- taskOutputToolName: taskTools.output,
102
- listTools: this.#options.listTools,
103
- signal: request.signal,
104
- emitStatus: () => {},
105
- marshalToolResult,
106
- });
84
+ async #call(request: BridgeToolCallRequest): Promise<unknown> {
85
+ return await routeBridgeToolCall(this.#options, request);
107
86
  }
108
87
 
109
88
  async getKernel(language: EvalLanguage, onMessage: (message: KernelToHostMessage) => void): Promise<EvalKernel> {
@@ -170,7 +149,6 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
170
149
  const kernels = [...this.#kernels.values()];
171
150
  const bridge = this.#bridge;
172
151
  this.#kernels.clear();
173
- for (const id of this.#registrations.values()) kernelRegistry.unregister(id);
174
152
  this.#registrations.clear();
175
153
  this.#onMessageRefs.clear();
176
154
  this.#bridge = undefined;
@@ -194,36 +172,29 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
194
172
  generation: number,
195
173
  ): Promise<EvalKernel> {
196
174
  await assertSessionCwdAvailable(this.#options.cwd);
197
- // py/rb/jl instances can die; the session holds one replaceable kernel per language so every
198
- // cell that kept a reference to it survives the death (JS heals its own worker).
199
- let memory: RegisteredKernelSource | undefined;
200
- const kernel =
201
- language === "js"
202
- ? (() => {
203
- return this.#createKernel(language, onMessage).then((created) => {
204
- memory = created.memory;
205
- return created.kernel;
206
- });
207
- })()
208
- : ReplaceableKernel.create(language, async (lifecycle) => {
209
- const created = await this.#createKernel(language, onMessage, lifecycle);
210
- memory ??= created.memory;
211
- return created.kernel;
212
- });
213
- const resolvedKernel = await kernel;
175
+ const started = await this.#startLanguageKernel(language, onMessage);
176
+ const idleParkMinutes = this.#options.settings.memory?.idleParkMinutes ?? 0;
177
+ const resolvedKernel = parkWhenIdle(
178
+ language,
179
+ idleParkMinutes,
180
+ started.kernel,
181
+ async () => {
182
+ const restarted = await this.#startLanguageKernel(language, onMessage);
183
+ if (generation !== this.#generation) {
184
+ await restarted.kernel.close();
185
+ throw new CodemodeSessionDisposedError();
186
+ }
187
+ this.#registrations.register(language, restarted.memory);
188
+ return restarted.kernel;
189
+ },
190
+ () => this.#registrations.unregister(language),
191
+ );
214
192
  if (generation !== this.#generation) {
215
193
  await resolvedKernel.close();
216
194
  throw new CodemodeSessionDisposedError();
217
195
  }
218
196
  this.#kernels.set(language, resolvedKernel);
219
- this.#registrations.set(
220
- language,
221
- registerKernel(
222
- this.#options.ownerSessionId ?? this.#options.sessionId,
223
- language,
224
- memory as RegisteredKernelSource,
225
- ),
226
- );
197
+ this.#registrations.register(language, started.memory);
227
198
  // The directory can vanish while the interpreter starts; every caller sharing this creation
228
199
  // must see that, not only the next one. The kernel stays stored and dispose still closes it.
229
200
  await assertSessionCwdAvailable(this.#options.cwd);
@@ -232,6 +203,22 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
232
203
  return resolvedKernel;
233
204
  }
234
205
 
206
+ // py/rb/jl instances can die; the session holds one replaceable kernel per language so every
207
+ // cell that kept a reference to it survives the death (JS heals its own worker).
208
+ async #startLanguageKernel(
209
+ language: EvalLanguage,
210
+ onMessage: (message: KernelToHostMessage) => void,
211
+ ): Promise<{ readonly kernel: EvalKernel; readonly memory: RegisteredKernelSource | undefined }> {
212
+ if (language === "js") return await this.#createKernel(language, onMessage);
213
+ let memory: RegisteredKernelSource | undefined;
214
+ const kernel = await ReplaceableKernel.create(language, async (lifecycle) => {
215
+ const created = await this.#createKernel(language, onMessage, lifecycle);
216
+ memory ??= created.memory;
217
+ return created.kernel;
218
+ });
219
+ return { kernel, memory };
220
+ }
221
+
235
222
  async #createKernel(
236
223
  language: EvalLanguage,
237
224
  onMessage: (message: KernelToHostMessage) => void,
@@ -2,6 +2,7 @@ import { join } from "node:path";
2
2
  import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
3
3
  import type { SessionEnvironment } from "../session-env.ts";
4
4
  import type { KernelLifecycle } from "../shared/kernel-death.ts";
5
+ import { readProcessGroupCpuTime } from "../shared/process-group-cpu.ts";
5
6
  import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
6
7
  import { SubprocessKernel, type SubprocessKernelMemory, type SubprocessSpawn } from "../shared/subprocess-kernel.ts";
7
8
 
@@ -14,7 +15,7 @@ export interface JuliaKernelStartOptions extends KernelLifecycle {
14
15
  readonly command?: string;
15
16
  readonly spawn?: SubprocessSpawn;
16
17
  readonly onMessage?: (message: KernelToHostMessage) => void;
17
- /** Ceiling-only memory management (no notice or globals list: the runner reports no memory). */
18
+ /** Memory management: the runner reports its largest globals and the host reads the interpreter footprint for the ceiling. */
18
19
  readonly memory?: SubprocessKernelMemory;
19
20
  }
20
21
 
@@ -54,6 +55,7 @@ export class JuliaKernel extends SubprocessKernel {
54
55
  onMessage: options.onMessage,
55
56
  memory: options.memory && { language: "jl", ...options.memory },
56
57
  onDeath: options.onDeath,
58
+ startup: { label: "Julia", readGroupCpuTime: readProcessGroupCpuTime },
57
59
  });
58
60
  }
59
61
  }