@code-yeongyu/senpi-codemode 2026.10.5 → 2026.10.7

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.
Files changed (68) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +20 -2
  3. package/package.json +4 -4
  4. package/src/bridge/kernel-tools-protocol.ts +1 -1
  5. package/src/bridge/reserved.ts +10 -0
  6. package/src/bridges/eval-virtual-schemas.ts +79 -0
  7. package/src/bridges/handle-bridge.ts +72 -0
  8. package/src/bridges/reserved-dispatch.ts +20 -2
  9. package/src/bridges/schema-bridge.ts +4 -0
  10. package/src/completion/handle.ts +69 -0
  11. package/src/completion/tool-bridge.ts +31 -12
  12. package/src/config/feature-settings.ts +110 -0
  13. package/src/config/memory-settings.ts +8 -0
  14. package/src/config/settings-constants.ts +7 -0
  15. package/src/config/settings-overrides.ts +72 -0
  16. package/src/config/settings.ts +35 -78
  17. package/src/config/unknown-settings.ts +19 -0
  18. package/src/extension/bridge-dispatch.ts +41 -0
  19. package/src/extension/bridge-tool-call.ts +41 -0
  20. package/src/extension/host-facts.ts +18 -0
  21. package/src/extension/idle-parking-kernel.ts +272 -0
  22. package/src/extension/kernel-registration.ts +25 -0
  23. package/src/extension/kernel-tools-probe.ts +24 -0
  24. package/src/extension/removed-tool-hints.ts +15 -0
  25. package/src/extension/runtime-factory.ts +3 -0
  26. package/src/extension/session-manager-contract.ts +3 -0
  27. package/src/extension/session-manager.ts +58 -58
  28. package/src/extension/start-recovery.ts +111 -0
  29. package/src/handles/completion-handles.ts +202 -0
  30. package/src/handles/handle-args.ts +103 -0
  31. package/src/handles/handle-registry.ts +164 -0
  32. package/src/handles/wait.ts +214 -0
  33. package/src/handles/watch-queue.ts +102 -0
  34. package/src/index.ts +99 -94
  35. package/src/kernels/jl/handles.jl +124 -0
  36. package/src/kernels/jl/kernel.ts +3 -1
  37. package/src/kernels/jl/prelude.jl +9 -0
  38. package/src/kernels/jl/runner.jl +160 -1
  39. package/src/kernels/js/context-manager.ts +1 -1
  40. package/src/kernels/js/handles.js +93 -0
  41. package/src/kernels/js/kernel-tools-define.d.ts +6 -0
  42. package/src/kernels/js/kernel-tools-define.js +3 -1
  43. package/src/kernels/js/kernel-tools-registry.d.ts +2 -0
  44. package/src/kernels/js/kernel-tools-registry.js +18 -9
  45. package/src/kernels/js/kernel-tools-schema.js +6 -2
  46. package/src/kernels/js/kernel-tools-types.ts +2 -1
  47. package/src/kernels/js/worker-global-sizes.js +84 -14
  48. package/src/kernels/js/worker-runtime.js +10 -1
  49. package/src/kernels/py/host-cell.ts +42 -0
  50. package/src/kernels/py/kernel-contract.ts +2 -0
  51. package/src/kernels/py/kernel.ts +48 -4
  52. package/src/kernels/py/prelude.py +145 -4
  53. package/src/kernels/rb/handles.rb +97 -0
  54. package/src/kernels/rb/kernel.ts +3 -1
  55. package/src/kernels/rb/paths.rb +37 -0
  56. package/src/kernels/rb/prelude.rb +15 -38
  57. package/src/kernels/rb/runner.rb +141 -0
  58. package/src/kernels/shared/kernel-memory-host.ts +14 -6
  59. package/src/kernels/{js/kernel-tools-host.ts → shared/kernel-tools-pump.ts} +2 -2
  60. package/src/kernels/shared/process-group-cpu.ts +139 -0
  61. package/src/kernels/shared/subprocess-contract.ts +3 -0
  62. package/src/kernels/shared/subprocess-kernel.ts +33 -1
  63. package/src/kernels/shared/subprocess-startup.ts +90 -0
  64. package/src/tool/cell-handler.ts +21 -18
  65. package/src/tool/cell-reserved-dispatch.ts +51 -0
  66. package/src/tool/eval-tool-options.ts +3 -0
  67. package/src/tool/run-eval-cell.ts +7 -6
  68. package/src/tool/types.ts +15 -0
@@ -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
+ }
@@ -0,0 +1,15 @@
1
+ /** Migration guidance for the tools eval replaced; the host returns it when a model calls them. */
2
+ export interface RemovedToolHintRegistrar {
3
+ registerRemovedToolHint(name: string, hint: string): void;
4
+ }
5
+
6
+ export const WAIT_REMOVED_TOOL_HINT =
7
+ "wait is not a tool; inside a cell use the wait(handles) helper (tool_schema('eval:wait')), or let detached cells notify.";
8
+
9
+ export function registerRemovedToolHints(pi: RemovedToolHintRegistrar): void {
10
+ pi.registerRemovedToolHint(
11
+ "exec",
12
+ 'exec was removed; use eval({ language: "js", code }) instead. Long eval cells detach on their own and notify when complete.',
13
+ );
14
+ pi.registerRemovedToolHint("wait", WAIT_REMOVED_TOOL_HINT);
15
+ }
@@ -8,6 +8,7 @@ import {
8
8
  type ResolvedCodemodeSettings,
9
9
  resolveEnabledLanguages,
10
10
  } from "../config/settings.ts";
11
+ import type { HandleRegistry } from "../handles/handle-registry.ts";
11
12
  import {
12
13
  createInterpreterDetector,
13
14
  getInterpreterAvailability,
@@ -54,6 +55,7 @@ export async function createRuntime(
54
55
  event: unknown,
55
56
  complete: (request: CompletionRequest, ctx: ExtensionContext) => Promise<CompletionResult>,
56
57
  options: RuntimeFactoryOptions,
58
+ handles?: HandleRegistry,
57
59
  ): Promise<SessionRuntime> {
58
60
  const loaded = await loadCodemodeSettings({ cwd: ctx.cwd });
59
61
  const settings: ResolvedCodemodeSettings = {
@@ -81,6 +83,7 @@ export async function createRuntime(
81
83
  executeTool,
82
84
  listTools: () => pi.getAllTools(),
83
85
  complete,
86
+ ...(handles === undefined ? {} : { handles }),
84
87
  });
85
88
  return {
86
89
  sessionId,
@@ -2,6 +2,7 @@ import type { ExtensionContext } from "@code-yeongyu/senpi";
2
2
  import type { EvalSchemaToolInfo } from "../bridges/schema-bridge.ts";
3
3
  import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
4
4
  import type { CodemodeSettings } from "../config/settings.ts";
5
+ import type { HandleRegistry } from "../handles/handle-registry.ts";
5
6
  import type { InterpreterAvailability } from "../interpreters/detect.ts";
6
7
  import type { SessionEnvironment } from "../kernels/session-env.ts";
7
8
  import type { EvalKernelManager, ExecuteTool } from "../tool/types.ts";
@@ -39,4 +40,6 @@ export interface CreateCodemodeSessionManagerOptions {
39
40
  readonly executeTool: ExecuteTool;
40
41
  readonly listTools?: () => readonly EvalSchemaToolInfo[];
41
42
  readonly complete: (request: CompletionRequest, ctx: ExtensionContext) => Promise<CompletionResult>;
43
+ /** The session generation's handle registry; subprocess kernels reach `wait()`/`handle()` through the bridge. */
44
+ readonly handles?: HandleRegistry;
42
45
  }
@@ -2,22 +2,22 @@ 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 BridgeDispatchContext, dispatchBridgeCompletion } from "./bridge-dispatch.ts";
12
+ import { type BridgeToolCallRequest, routeBridgeToolCall } from "./bridge-tool-call.ts";
13
+ import { parkWhenIdle } from "./idle-parking-kernel.ts";
14
14
  import {
15
15
  javaScriptKernelMemory,
16
- registerKernel,
16
+ SessionKernelRegistrations,
17
17
  type StartedKernel,
18
18
  startSubprocessKernel,
19
19
  } from "./kernel-registration.ts";
20
- import { kernelRegistry, type RegisteredKernelSource } from "./kernel-registry.ts";
20
+ import type { RegisteredKernelSource } from "./kernel-registry.ts";
21
21
  import { ReplaceableKernel } from "./kernel-replacement.ts";
22
22
  import { assertSessionCwdAvailable } from "./session-cwd.ts";
23
23
  import type {
@@ -61,7 +61,7 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
61
61
  readonly #options: CreateCodemodeSessionManagerOptions;
62
62
  #bridge: BridgeServerHandle | undefined;
63
63
  #kernels = new Map<EvalLanguage, EvalKernel>();
64
- readonly #registrations = new Map<EvalLanguage, string>();
64
+ readonly #registrations: SessionKernelRegistrations;
65
65
  #kernelCreations = new Map<EvalLanguage, Promise<EvalKernel>>();
66
66
  #onMessageRefs = new Map<EvalLanguage, (message: KernelToHostMessage) => void>();
67
67
  #context: ExtensionContext | undefined;
@@ -70,40 +70,27 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
70
70
 
71
71
  constructor(options: CreateCodemodeSessionManagerOptions) {
72
72
  this.#options = options;
73
+ this.#registrations = new SessionKernelRegistrations(options.ownerSessionId ?? options.sessionId);
73
74
  }
74
75
 
75
76
  async start(): Promise<void> {
77
+ const manager = this;
78
+ const dispatch: BridgeDispatchContext = {
79
+ get context() {
80
+ return manager.#context;
81
+ },
82
+ contextFor: (signal) => this.#contextFor(signal),
83
+ requireContext: () => this.#requireContext(),
84
+ };
76
85
  this.#bridge = await startBridgeServer({
77
86
  onCall: async (request) => await this.#call(request),
78
87
  onEmit: async () => undefined,
79
- onCompletion: async (request) =>
80
- this.#options.complete({ prompt: request.prompt, opts: request.opts }, this.#contextFor(request.signal)),
88
+ onCompletion: async (request) => await dispatchBridgeCompletion(this.#options, request, dispatch),
81
89
  });
82
90
  }
83
91
 
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
- });
92
+ async #call(request: BridgeToolCallRequest): Promise<unknown> {
93
+ return await routeBridgeToolCall(this.#options, request, this.#context?.evalHandleHost);
107
94
  }
108
95
 
109
96
  async getKernel(language: EvalLanguage, onMessage: (message: KernelToHostMessage) => void): Promise<EvalKernel> {
@@ -170,7 +157,6 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
170
157
  const kernels = [...this.#kernels.values()];
171
158
  const bridge = this.#bridge;
172
159
  this.#kernels.clear();
173
- for (const id of this.#registrations.values()) kernelRegistry.unregister(id);
174
160
  this.#registrations.clear();
175
161
  this.#onMessageRefs.clear();
176
162
  this.#bridge = undefined;
@@ -194,36 +180,29 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
194
180
  generation: number,
195
181
  ): Promise<EvalKernel> {
196
182
  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;
183
+ const started = await this.#startLanguageKernel(language, onMessage);
184
+ const idleParkMinutes = this.#options.settings.memory?.idleParkMinutes ?? 0;
185
+ const resolvedKernel = parkWhenIdle(
186
+ language,
187
+ idleParkMinutes,
188
+ started.kernel,
189
+ async () => {
190
+ const restarted = await this.#startLanguageKernel(language, onMessage);
191
+ if (generation !== this.#generation) {
192
+ await restarted.kernel.close();
193
+ throw new CodemodeSessionDisposedError();
194
+ }
195
+ this.#registrations.register(language, restarted.memory);
196
+ return restarted.kernel;
197
+ },
198
+ () => this.#registrations.unregister(language),
199
+ );
214
200
  if (generation !== this.#generation) {
215
201
  await resolvedKernel.close();
216
202
  throw new CodemodeSessionDisposedError();
217
203
  }
218
204
  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
- );
205
+ this.#registrations.register(language, started.memory);
227
206
  // The directory can vanish while the interpreter starts; every caller sharing this creation
228
207
  // must see that, not only the next one. The kernel stays stored and dispose still closes it.
229
208
  await assertSessionCwdAvailable(this.#options.cwd);
@@ -232,6 +211,22 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
232
211
  return resolvedKernel;
233
212
  }
234
213
 
214
+ // py/rb/jl instances can die; the session holds one replaceable kernel per language so every
215
+ // cell that kept a reference to it survives the death (JS heals its own worker).
216
+ async #startLanguageKernel(
217
+ language: EvalLanguage,
218
+ onMessage: (message: KernelToHostMessage) => void,
219
+ ): Promise<{ readonly kernel: EvalKernel; readonly memory: RegisteredKernelSource | undefined }> {
220
+ if (language === "js") return await this.#createKernel(language, onMessage);
221
+ let memory: RegisteredKernelSource | undefined;
222
+ const kernel = await ReplaceableKernel.create(language, async (lifecycle) => {
223
+ const created = await this.#createKernel(language, onMessage, lifecycle);
224
+ memory ??= created.memory;
225
+ return created.kernel;
226
+ });
227
+ return { kernel, memory };
228
+ }
229
+
235
230
  async #createKernel(
236
231
  language: EvalLanguage,
237
232
  onMessage: (message: KernelToHostMessage) => void,
@@ -293,9 +288,14 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
293
288
  return names;
294
289
  }
295
290
 
296
- #contextFor(signal: AbortSignal): ExtensionContext {
291
+ #requireContext(): ExtensionContext {
297
292
  const ctx = this.#context;
298
293
  if (!ctx) throw new CodemodeContextUnavailableError();
294
+ return ctx;
295
+ }
296
+
297
+ #contextFor(signal: AbortSignal): ExtensionContext {
298
+ const ctx = this.#requireContext();
299
299
  return { ...ctx, signal: ctx.signal ? AbortSignal.any([ctx.signal, signal]) : signal };
300
300
  }
301
301
  }
@@ -0,0 +1,111 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { ExtensionContext } from "@code-yeongyu/senpi";
3
+ import type { createEvalTool } from "../tool/eval-tool.ts";
4
+ import { CodemodeSessionDisposedError } from "./session-manager.ts";
5
+ import { CodemodeSessionNotStartedError } from "./session-manager-proxy.ts";
6
+
7
+ export class CodemodeRuntimeRecreationError extends Error {
8
+ readonly name = "CodemodeRuntimeRecreationError";
9
+
10
+ constructor(reason: string) {
11
+ super(`codemode runtime could not be re-created: ${reason}. Start a new session or reload to bring eval back.`);
12
+ }
13
+ }
14
+
15
+ type StartSession = (event: unknown, ctx: ExtensionContext) => Promise<void>;
16
+
17
+ /**
18
+ * A session_start whose runtime could not be created leaves the session running with no usable manager. The
19
+ * next eval re-creates the runtime once for that failed start (concurrent evals share the attempt) and runs; a
20
+ * failed re-creation is reported to every later eval until the next session_start. One stderr line names the
21
+ * failed start with a hashed session id so a recurrence stays visible. A session that ended is never recovered.
22
+ */
23
+ export class StartRecovery {
24
+ readonly #startSession: StartSession;
25
+ readonly #report: (line: string) => void;
26
+ #failedStartEvent: { readonly event: unknown } | undefined;
27
+ #recreation: Promise<void> | undefined;
28
+ #recreationFailure: CodemodeRuntimeRecreationError | undefined;
29
+
30
+ constructor(
31
+ startSession: StartSession,
32
+ report: (line: string) => void = (line) => globalThis.process.stderr.write(`${line}\n`),
33
+ ) {
34
+ this.#startSession = startSession;
35
+ this.#report = report;
36
+ }
37
+
38
+ async runStart(event: unknown, ctx: ExtensionContext): Promise<void> {
39
+ try {
40
+ await this.#startSession(event, ctx);
41
+ this.#reset();
42
+ } catch (error) {
43
+ this.#reset();
44
+ this.#failedStartEvent = { event };
45
+ throw error;
46
+ }
47
+ }
48
+
49
+ sessionEnded(): void {
50
+ this.#reset();
51
+ }
52
+
53
+ wrapExecute<Args extends unknown[], Result>(
54
+ execute: (...args: Args) => Promise<Result>,
55
+ contextOf: (args: Args) => ExtensionContext,
56
+ ): (...args: Args) => Promise<Result> {
57
+ return async (...args) => {
58
+ try {
59
+ return await execute(...args);
60
+ } catch (error) {
61
+ if (!(error instanceof CodemodeSessionDisposedError || error instanceof CodemodeSessionNotStartedError)) {
62
+ throw error;
63
+ }
64
+ if (this.#recreationFailure !== undefined) throw this.#recreationFailure;
65
+ const recreation = this.#recreation ?? this.#beginRecreation(contextOf(args));
66
+ if (recreation === undefined) throw error;
67
+ await recreation;
68
+ return await execute(...args);
69
+ }
70
+ };
71
+ }
72
+
73
+ #beginRecreation(ctx: ExtensionContext): Promise<void> | undefined {
74
+ const failed = this.#failedStartEvent;
75
+ if (failed === undefined) return undefined;
76
+ this.#report(
77
+ `[senpi-codemode] eval re-created a runtime left disposed by a failed session_start (session ${sessionHash(ctx)})`,
78
+ );
79
+ const recreation = this.#startSession(failed.event, ctx).then(
80
+ () => {
81
+ this.#recreation = undefined;
82
+ this.#reset();
83
+ },
84
+ (error: unknown) => {
85
+ this.#recreation = undefined;
86
+ this.#recreationFailure = new CodemodeRuntimeRecreationError(
87
+ error instanceof Error ? error.message : String(error),
88
+ );
89
+ throw this.#recreationFailure;
90
+ },
91
+ );
92
+ this.#recreation = recreation;
93
+ return recreation;
94
+ }
95
+
96
+ #reset(): void {
97
+ this.#failedStartEvent = undefined;
98
+ this.#recreationFailure = undefined;
99
+ }
100
+ }
101
+
102
+ export function withStartRecovery<Tool extends ReturnType<typeof createEvalTool>>(
103
+ recovery: StartRecovery,
104
+ tool: Tool,
105
+ ): Tool {
106
+ return { ...tool, execute: recovery.wrapExecute(tool.execute, (args) => args[4]) };
107
+ }
108
+
109
+ function sessionHash(ctx: ExtensionContext): string {
110
+ return createHash("sha256").update(ctx.sessionManager.getSessionId()).digest("hex").slice(0, 12);
111
+ }