@code-yeongyu/senpi-codemode 2026.7.25-2

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 (63) hide show
  1. package/CHANGELOG.md +250 -0
  2. package/LICENSE +22 -0
  3. package/README.md +161 -0
  4. package/package.json +58 -0
  5. package/src/bridge/http-server.ts +236 -0
  6. package/src/bridge/protocol.ts +198 -0
  7. package/src/bridge/reserved.ts +9 -0
  8. package/src/bridges/agent-bridge.ts +197 -0
  9. package/src/bridges/output-bridge.ts +96 -0
  10. package/src/bridges/schema-injection.ts +3 -0
  11. package/src/codemode/runtime.ts +258 -0
  12. package/src/codemode/tools.ts +106 -0
  13. package/src/completion/handler.ts +192 -0
  14. package/src/completion/tool-bridge.ts +55 -0
  15. package/src/config/settings.ts +215 -0
  16. package/src/extension/runtime-factory.ts +114 -0
  17. package/src/extension/session-manager-proxy.ts +116 -0
  18. package/src/extension/session-manager.ts +215 -0
  19. package/src/host-sdk.ts +1 -0
  20. package/src/index.ts +181 -0
  21. package/src/interpreters/detect.ts +161 -0
  22. package/src/kernels/jl/kernel.ts +37 -0
  23. package/src/kernels/jl/prelude.jl +283 -0
  24. package/src/kernels/jl/runner.jl +327 -0
  25. package/src/kernels/js/context-manager.ts +296 -0
  26. package/src/kernels/js/inline-worker-entry.js +23 -0
  27. package/src/kernels/js/inline-worker.ts +15 -0
  28. package/src/kernels/js/kernel-contract.ts +38 -0
  29. package/src/kernels/js/local-module-loader.ts +108 -0
  30. package/src/kernels/js/prelude.ts +15 -0
  31. package/src/kernels/js/rewrite-imports.ts +164 -0
  32. package/src/kernels/js/run-queue.ts +82 -0
  33. package/src/kernels/js/worker-core.d.ts +18 -0
  34. package/src/kernels/js/worker-core.js +94 -0
  35. package/src/kernels/js/worker-entry.js +23 -0
  36. package/src/kernels/js/worker-host.ts +117 -0
  37. package/src/kernels/js/worker-indirect-eval.js +88 -0
  38. package/src/kernels/js/worker-runtime.js +401 -0
  39. package/src/kernels/py/kernel-contract.ts +32 -0
  40. package/src/kernels/py/kernel.ts +290 -0
  41. package/src/kernels/py/prelude.py +954 -0
  42. package/src/kernels/py/process.ts +119 -0
  43. package/src/kernels/py/transport.ts +237 -0
  44. package/src/kernels/rb/kernel.ts +26 -0
  45. package/src/kernels/rb/prelude.rb +270 -0
  46. package/src/kernels/rb/runner.rb +204 -0
  47. package/src/kernels/shared/subprocess-contract.ts +22 -0
  48. package/src/kernels/shared/subprocess-kernel.ts +266 -0
  49. package/src/kernels/shared/subprocess-process.ts +174 -0
  50. package/src/kernels/shared/subprocess-queue.ts +101 -0
  51. package/src/kernels/shared/subprocess-run.ts +98 -0
  52. package/src/output/output-meta.ts +89 -0
  53. package/src/output/streaming-output.ts +296 -0
  54. package/src/prompt/eval-prompt.ts +319 -0
  55. package/src/timeouts/bridge-timeout.ts +16 -0
  56. package/src/timeouts/idle-timeout.ts +84 -0
  57. package/src/tool/cell-handler.ts +279 -0
  58. package/src/tool/eval-tool.ts +285 -0
  59. package/src/tool/image.ts +274 -0
  60. package/src/tool/json-tree.ts +247 -0
  61. package/src/tool/render.ts +876 -0
  62. package/src/tool/status-events.ts +12 -0
  63. package/src/tool/types.ts +114 -0
@@ -0,0 +1,215 @@
1
+ import { join } from "node:path";
2
+ import type { ExtensionContext } from "@code-yeongyu/senpi";
3
+ import { type BridgeServerHandle, startBridgeServer } from "../bridge/http-server.ts";
4
+ import type { KernelToHostMessage } from "../bridge/protocol.ts";
5
+ import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
6
+ import type { CodemodeSettings } from "../config/settings.ts";
7
+ import type { InterpreterAvailability } from "../interpreters/detect.ts";
8
+ import { JuliaKernel } from "../kernels/jl/kernel.ts";
9
+ import { JavaScriptKernel } from "../kernels/js/context-manager.ts";
10
+ import { PythonKernel } from "../kernels/py/kernel.ts";
11
+ import { RubyKernel } from "../kernels/rb/kernel.ts";
12
+ import type { EvalKernel, EvalKernelManager, EvalLanguage, ExecuteTool } from "../tool/types.ts";
13
+
14
+ export interface CodemodeSessionManager extends EvalKernelManager {
15
+ dispose(): Promise<void>;
16
+ complete(request: CompletionRequest, ctx: ExtensionContext): Promise<CompletionResult>;
17
+ setContext?(ctx: ExtensionContext): void;
18
+ }
19
+
20
+ export interface EvalExecutionTracker {
21
+ assertEvalExecutionAllowed(): void;
22
+ trackEvalExecution<Result>(execution: Promise<Result>, controller: AbortController): Promise<Result>;
23
+ }
24
+
25
+ export interface CreateCodemodeSessionManagerOptions {
26
+ readonly sessionId: string;
27
+ readonly cwd: string;
28
+ readonly settings: CodemodeSettings;
29
+ readonly availability: InterpreterAvailability;
30
+ /** Session-scoped roots exposed to kernel helpers such as local://. */
31
+ readonly localRoots?: Readonly<Record<string, string>>;
32
+ /** Session-adjacent directory used for persisted eval artifacts. */
33
+ readonly artifactsDir?: string;
34
+ readonly executeTool: ExecuteTool;
35
+ readonly complete: (request: CompletionRequest, ctx: ExtensionContext) => Promise<CompletionResult>;
36
+ }
37
+
38
+ export async function createCodemodeSessionManager(
39
+ options: CreateCodemodeSessionManagerOptions,
40
+ ): Promise<CodemodeSessionManager> {
41
+ const manager = new DefaultCodemodeSessionManager(options);
42
+ await manager.start();
43
+ return manager;
44
+ }
45
+
46
+ export class CodemodeSessionDisposedError extends Error {
47
+ readonly name = "CodemodeSessionDisposedError";
48
+
49
+ constructor() {
50
+ super("codemode session manager is disposed");
51
+ }
52
+ }
53
+
54
+ class CodemodeContextUnavailableError extends Error {
55
+ readonly name = "CodemodeContextUnavailableError";
56
+
57
+ constructor() {
58
+ super("codemode completion context is unavailable");
59
+ }
60
+ }
61
+
62
+ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
63
+ readonly #options: CreateCodemodeSessionManagerOptions;
64
+ #bridge: BridgeServerHandle | undefined;
65
+ #kernels = new Map<EvalLanguage, EvalKernel>();
66
+ #kernelCreations = new Map<EvalLanguage, Promise<EvalKernel>>();
67
+ #onMessageRefs = new Map<EvalLanguage, (message: KernelToHostMessage) => void>();
68
+ #context: ExtensionContext | undefined;
69
+ #generation = 0;
70
+ #disposePromise: Promise<void> | undefined;
71
+
72
+ constructor(options: CreateCodemodeSessionManagerOptions) {
73
+ this.#options = options;
74
+ }
75
+
76
+ async start(): Promise<void> {
77
+ this.#bridge = await startBridgeServer({
78
+ onCall: async (request) =>
79
+ this.#options.executeTool(request.toolName, request.args, { signal: request.signal }),
80
+ onEmit: async () => undefined,
81
+ onCompletion: async (request) =>
82
+ this.#options.complete({ prompt: request.prompt, opts: request.opts }, this.#contextFor(request.signal)),
83
+ });
84
+ }
85
+
86
+ async getKernel(language: EvalLanguage, onMessage: (message: KernelToHostMessage) => void): Promise<EvalKernel> {
87
+ if (this.#disposePromise) throw new CodemodeSessionDisposedError();
88
+ // Persistent kernels are reused across cells, but each cell needs its OWN
89
+ // onMessage (bound to that cell's streaming state). Rebind on every call via
90
+ // a stable dispatcher so the 2nd+ cell's text/display/log output is attributed
91
+ // to the current cell, not the one that first created the kernel.
92
+ this.#onMessageRefs.set(language, onMessage);
93
+ const existing = this.#kernels.get(language);
94
+ if (existing) return existing;
95
+ const pending = this.#kernelCreations.get(language);
96
+ if (pending) return await pending;
97
+ const dispatch = (message: KernelToHostMessage): void => this.#onMessageRefs.get(language)?.(message);
98
+ const generation = this.#generation;
99
+ const creation = this.#createAndStoreKernel(language, dispatch, generation);
100
+ this.#kernelCreations.set(language, creation);
101
+ try {
102
+ return await creation;
103
+ } finally {
104
+ if (this.#kernelCreations.get(language) === creation) this.#kernelCreations.delete(language);
105
+ }
106
+ }
107
+
108
+ async complete(request: CompletionRequest, ctx: ExtensionContext): Promise<CompletionResult> {
109
+ return await this.#options.complete(request, ctx);
110
+ }
111
+
112
+ setContext(ctx: ExtensionContext): void {
113
+ this.#context = ctx;
114
+ }
115
+
116
+ dispose(): Promise<void> {
117
+ if (this.#disposePromise) return this.#disposePromise;
118
+ this.#generation++;
119
+ this.#disposePromise = this.#disposeGeneration();
120
+ return this.#disposePromise;
121
+ }
122
+
123
+ async #disposeGeneration(): Promise<void> {
124
+ await Promise.allSettled(this.#kernelCreations.values());
125
+ const kernels = [...this.#kernels.values()];
126
+ const bridge = this.#bridge;
127
+ this.#kernels.clear();
128
+ this.#onMessageRefs.clear();
129
+ this.#bridge = undefined;
130
+ this.#context = undefined;
131
+ const failures: unknown[] = [];
132
+ for (const outcome of await Promise.allSettled(kernels.map((kernel) => kernel.close()))) {
133
+ if (outcome.status === "rejected") failures.push(outcome.reason);
134
+ }
135
+ if (bridge) {
136
+ const [outcome] = await Promise.allSettled([bridge.close()]);
137
+ if (outcome?.status === "rejected") failures.push(outcome.reason);
138
+ }
139
+ if (failures.length > 0) {
140
+ throw new AggregateError(failures, "Failed to dispose codemode session manager");
141
+ }
142
+ }
143
+
144
+ async #createAndStoreKernel(
145
+ language: EvalLanguage,
146
+ onMessage: (message: KernelToHostMessage) => void,
147
+ generation: number,
148
+ ): Promise<EvalKernel> {
149
+ const kernel = await this.#createKernel(language, onMessage);
150
+ if (generation !== this.#generation) {
151
+ await kernel.close();
152
+ throw new CodemodeSessionDisposedError();
153
+ }
154
+ this.#kernels.set(language, kernel);
155
+ return kernel;
156
+ }
157
+
158
+ async #createKernel(language: EvalLanguage, onMessage: (message: KernelToHostMessage) => void): Promise<EvalKernel> {
159
+ const bridge = this.#bridge;
160
+ if (!bridge) throw new Error("codemode bridge server is not running");
161
+ const configuredPoolWidth = this.#options.settings.parallelPoolWidth;
162
+ const parallelPoolWidth = Number.isFinite(configuredPoolWidth) ? Math.max(1, Math.trunc(configuredPoolWidth)) : 1;
163
+ if (language === "js") {
164
+ return new JavaScriptKernel({
165
+ sessionId: this.#options.sessionId,
166
+ cwd: this.#options.cwd,
167
+ parallelPoolWidth,
168
+ onMessage,
169
+ });
170
+ }
171
+ const detected = this.#options.availability[language].detected;
172
+ if (!detected.ok) throw new Error(`No ${language} interpreter is available`);
173
+ const localRoots =
174
+ this.#options.localRoots ??
175
+ (this.#options.artifactsDir ? { local: join(this.#options.artifactsDir, "local") } : undefined);
176
+ const connection = {
177
+ port: bridge.port,
178
+ token: bridge.token,
179
+ parallelPoolWidth,
180
+ ...(localRoots ? { localRoots: { ...localRoots } } : {}),
181
+ ...(this.#options.artifactsDir ? { artifactsDir: this.#options.artifactsDir } : {}),
182
+ };
183
+ if (language === "py") {
184
+ return await PythonKernel.start({
185
+ interpreterPath: detected.path,
186
+ sessionId: this.#options.sessionId,
187
+ cwd: this.#options.cwd,
188
+ connection,
189
+ onMessage,
190
+ });
191
+ }
192
+ if (language === "rb") {
193
+ return RubyKernel.start({
194
+ command: detected.path,
195
+ sessionId: this.#options.sessionId,
196
+ cwd: this.#options.cwd,
197
+ connection,
198
+ onMessage,
199
+ });
200
+ }
201
+ return JuliaKernel.start({
202
+ command: detected.path,
203
+ sessionId: this.#options.sessionId,
204
+ cwd: this.#options.cwd,
205
+ connection,
206
+ onMessage,
207
+ });
208
+ }
209
+
210
+ #contextFor(signal: AbortSignal): ExtensionContext {
211
+ const ctx = this.#context;
212
+ if (!ctx) throw new CodemodeContextUnavailableError();
213
+ return { ...ctx, signal: ctx.signal ? AbortSignal.any([ctx.signal, signal]) : signal };
214
+ }
215
+ }
@@ -0,0 +1 @@
1
+ export { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, truncateTail } from "@code-yeongyu/senpi";
package/src/index.ts ADDED
@@ -0,0 +1,181 @@
1
+ import * as os from "node:os";
2
+ import type { ExtensionContext } from "@code-yeongyu/senpi";
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
+ import { type CompletionRequest, type CompletionResult, createCompletionHandler } from "./completion/handler.ts";
7
+ import { defaultCodemodeSettings } from "./config/settings.ts";
8
+ import {
9
+ createExecuteTool,
10
+ createRuntime,
11
+ enabledLanguagesFrom,
12
+ type SessionRuntime,
13
+ } from "./extension/runtime-factory.ts";
14
+ import type { CodemodeSessionManager, CreateCodemodeSessionManagerOptions } from "./extension/session-manager.ts";
15
+ import { SessionManagerProxy } from "./extension/session-manager-proxy.ts";
16
+ import { createEvalTool } from "./tool/eval-tool.ts";
17
+ import { renderEvalCall, renderEvalResult } from "./tool/render.ts";
18
+
19
+ const SESSION_LIFECYCLE_EVENTS = [
20
+ "session_start",
21
+ "session_shutdown",
22
+ "session_before_switch",
23
+ "session_before_fork",
24
+ ] as const;
25
+
26
+ type SessionLifecycleEvent = (typeof SESSION_LIFECYCLE_EVENTS)[number];
27
+
28
+ type CodemodeEvent = SessionLifecycleEvent | "model_select";
29
+
30
+ export interface CodemodeExtensionAPI {
31
+ registerTool(tool: ReturnType<typeof createEvalTool>): void;
32
+ on(event: CodemodeEvent, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void): void;
33
+ executeTool: AgentExecuteTool;
34
+ getActiveTools(): string[];
35
+ setActiveTools?(toolNames: string[]): void | Promise<void>;
36
+ getAllTools?(): readonly { readonly name: string }[];
37
+ }
38
+
39
+ export interface SenpiCodemodeOptions {
40
+ readonly createSessionManager?: (
41
+ options: CreateCodemodeSessionManagerOptions,
42
+ ) => CodemodeSessionManager | Promise<CodemodeSessionManager>;
43
+ readonly complete?: (request: CompletionRequest, ctx: ExtensionContext) => Promise<CompletionResult>;
44
+ }
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
+ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCodemodeOptions = {}): void {
53
+ const manager = new SessionManagerProxy();
54
+ const complete = options.complete ?? ((request, ctx) => createCompletionHandler()(ctx)(request));
55
+ const renderers = { renderCall: renderEvalCall, renderResult: renderEvalResult };
56
+ let activeRuntime: (SessionRuntime & { readonly codeMode?: CodeModeSessionRuntime }) | undefined;
57
+ let activeModelId: string | undefined;
58
+ const registerEvalForRuntime = (runtime: SessionRuntime, modelId: string | undefined): void => {
59
+ pi.registerTool(
60
+ createEvalTool({
61
+ enabledLanguages: runtime.enabledLanguages,
62
+ kernelManager: manager,
63
+ cellTimeoutSeconds: runtime.settings.cellTimeoutSeconds,
64
+ executeTool: runtime.executeTool,
65
+ complete,
66
+ settings: runtime.settings,
67
+ artifactsDir: runtime.artifactsDir,
68
+ executionTracker: manager,
69
+ renderers,
70
+ spawns: runtime.spawns,
71
+ spawnDefaultAgent: runtime.settings.taskTools.task,
72
+ hostLine: hostLine(),
73
+ ...(modelId === undefined ? {} : { modelId }),
74
+ }),
75
+ );
76
+ };
77
+ const dropRuntime = async (): Promise<void> => {
78
+ const codeMode = activeRuntime?.codeMode;
79
+ activeRuntime = undefined;
80
+ 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"));
93
+ };
94
+ pi.registerTool(
95
+ createEvalTool({
96
+ enabledLanguages: { py: true, js: true, rb: true, jl: true },
97
+ kernelManager: manager,
98
+ cellTimeoutSeconds: defaultCodemodeSettings.cellTimeoutSeconds,
99
+ executeTool: createExecuteTool(pi),
100
+ complete,
101
+ settings: defaultCodemodeSettings,
102
+ executionTracker: manager,
103
+ renderers,
104
+ hostLine: hostLine(),
105
+ }),
106
+ );
107
+
108
+ pi.on("session_start", async (event, ctx) => {
109
+ const previousCodeMode = activeRuntime?.codeMode;
110
+ const generation = manager.beginReplacement();
111
+ const runtime = await createRuntime(pi, ctx, event, complete, options);
112
+ const replaced = await manager.replace(generation, runtime.manager);
113
+ 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 }) };
124
+ activeModelId = ctx.model?.id;
125
+ registerEvalForRuntime(activeRuntime, activeModelId);
126
+ if (codeMode) await activateCodeModeTools(codeMode);
127
+ else await deactivateCodeModeTools();
128
+ });
129
+ pi.on("session_shutdown", async () => dropRuntime());
130
+ pi.on("session_before_switch", async () => dropRuntime());
131
+ pi.on("session_before_fork", async () => dropRuntime());
132
+ pi.on("model_select", async (event) => {
133
+ const runtime = activeRuntime;
134
+ if (runtime === undefined) return;
135
+ const modelId = modelIdFrom(event);
136
+ if (modelId === undefined || modelId === activeModelId) return;
137
+ 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);
160
+ });
161
+ }
162
+
163
+ function isDynamicCodeModeExtensionAPI(pi: CodemodeExtensionAPI): pi is DynamicCodeModeExtensionAPI {
164
+ return typeof pi.setActiveTools === "function" && typeof pi.getAllTools === "function";
165
+ }
166
+
167
+ function hostLine(): string {
168
+ const cpu = os.cpus()[0]?.model?.trim();
169
+ return [`${os.platform()} ${os.arch()}`, cpu, `${os.availableParallelism()} cores`]
170
+ .filter((part): part is string => !!part)
171
+ .join(" \u00b7 ");
172
+ }
173
+
174
+ function modelIdFrom(event: unknown): string | undefined {
175
+ if (typeof event !== "object" || event === null || !("model" in event)) return undefined;
176
+ const model = event.model;
177
+ if (typeof model !== "object" || model === null || !("id" in model)) return undefined;
178
+ return typeof model.id === "string" ? model.id : undefined;
179
+ }
180
+
181
+ export { enabledLanguagesFrom };
@@ -0,0 +1,161 @@
1
+ import { execFile } from "node:child_process";
2
+ import { platform as currentPlatform } from "node:os";
3
+ import { promisify } from "node:util";
4
+ import type { CodemodeSettings } from "../config/settings.ts";
5
+
6
+ const execFileAsync = promisify(execFile);
7
+ const probeTimeoutMs = 3_000;
8
+
9
+ export type CodemodeLanguage = "py" | "js" | "rb" | "jl";
10
+
11
+ export interface InterpreterDetected {
12
+ readonly ok: true;
13
+ readonly path: string;
14
+ readonly version: string;
15
+ }
16
+
17
+ export interface InterpreterUnavailable {
18
+ readonly ok: false;
19
+ }
20
+
21
+ export type InterpreterDetection = InterpreterDetected | InterpreterUnavailable;
22
+
23
+ export interface ExecFileProbeOptions {
24
+ readonly timeoutMs: number;
25
+ }
26
+
27
+ export type ExecFileProbe = (
28
+ command: string,
29
+ args: readonly string[],
30
+ options: ExecFileProbeOptions,
31
+ ) => Promise<{ readonly stdout: string; readonly stderr: string }>;
32
+
33
+ export interface CreateInterpreterDetectorOptions {
34
+ readonly platform?: NodeJS.Platform;
35
+ readonly execFile?: ExecFileProbe;
36
+ readonly nodeVersion?: string;
37
+ }
38
+
39
+ export interface InterpreterDetector {
40
+ detect(language: CodemodeLanguage): Promise<InterpreterDetection>;
41
+ }
42
+
43
+ export interface LanguageAvailability {
44
+ readonly enabled: boolean;
45
+ readonly detected: InterpreterDetection;
46
+ }
47
+
48
+ export type InterpreterAvailability = {
49
+ readonly [Language in CodemodeLanguage]: LanguageAvailability;
50
+ };
51
+
52
+ const unavailable: InterpreterUnavailable = { ok: false };
53
+
54
+ export function createInterpreterDetector(options: CreateInterpreterDetectorOptions = {}): InterpreterDetector {
55
+ const probe = options.execFile ?? defaultExecFileProbe;
56
+ const hostPlatform = options.platform ?? currentPlatform();
57
+ const nodeVersion = options.nodeVersion ?? process.versions.node;
58
+ const cache = new Map<CodemodeLanguage, Promise<InterpreterDetection>>();
59
+
60
+ return {
61
+ detect(language) {
62
+ const cached = cache.get(language);
63
+ if (cached !== undefined) {
64
+ return cached;
65
+ }
66
+
67
+ const pending = detectUncached(language, hostPlatform, probe, nodeVersion);
68
+ cache.set(language, pending);
69
+ return pending;
70
+ },
71
+ };
72
+ }
73
+
74
+ export async function getInterpreterAvailability(
75
+ settings: CodemodeSettings,
76
+ detector: InterpreterDetector,
77
+ ): Promise<InterpreterAvailability> {
78
+ return {
79
+ py: await availabilityFor("py", settings.languages.py, detector),
80
+ js: await availabilityFor("js", settings.languages.js, detector),
81
+ rb: await availabilityFor("rb", settings.languages.rb, detector),
82
+ jl: await availabilityFor("jl", settings.languages.jl, detector),
83
+ };
84
+ }
85
+
86
+ async function availabilityFor(
87
+ language: CodemodeLanguage,
88
+ enabled: boolean,
89
+ detector: InterpreterDetector,
90
+ ): Promise<LanguageAvailability> {
91
+ return {
92
+ enabled,
93
+ detected: enabled ? await detector.detect(language) : unavailable,
94
+ };
95
+ }
96
+
97
+ async function detectUncached(
98
+ language: CodemodeLanguage,
99
+ hostPlatform: NodeJS.Platform,
100
+ probe: ExecFileProbe,
101
+ nodeVersion: string,
102
+ ): Promise<InterpreterDetection> {
103
+ if (language === "js") {
104
+ return { ok: true, path: "node", version: nodeVersion };
105
+ }
106
+
107
+ for (const candidate of candidatesFor(language, hostPlatform)) {
108
+ const result = await probeCandidate(candidate, probe);
109
+ if (result.ok) {
110
+ return result;
111
+ }
112
+ }
113
+
114
+ return unavailable;
115
+ }
116
+
117
+ async function probeCandidate(candidate: string, probe: ExecFileProbe): Promise<InterpreterDetection> {
118
+ const invocation = candidateInvocation(candidate);
119
+ try {
120
+ const result = await probe(invocation.command, [...invocation.args, "--version"], { timeoutMs: probeTimeoutMs });
121
+ const version = parseVersion(`${result.stdout}\n${result.stderr}`);
122
+ return version === null ? unavailable : { ok: true, path: candidate, version };
123
+ } catch {
124
+ return unavailable;
125
+ }
126
+ }
127
+
128
+ function candidatesFor(language: CodemodeLanguage, hostPlatform: NodeJS.Platform): readonly string[] {
129
+ if (language === "py") {
130
+ return hostPlatform === "win32" ? ["python", "py -3", "python3"] : ["python3", "python"];
131
+ }
132
+ if (language === "rb") {
133
+ return ["ruby"];
134
+ }
135
+ if (language === "jl") {
136
+ return ["julia"];
137
+ }
138
+ return [];
139
+ }
140
+
141
+ function candidateInvocation(candidate: string): { readonly command: string; readonly args: readonly string[] } {
142
+ const [command, ...args] = candidate.split(" ");
143
+ return { command: command ?? candidate, args };
144
+ }
145
+
146
+ function parseVersion(output: string): string | null {
147
+ const match = /(?:Python|ruby|julia)\s+(?:version\s+)?v?(\d+(?:\.\d+){1,3})/i.exec(output.trim());
148
+ return match?.[1] ?? null;
149
+ }
150
+
151
+ async function defaultExecFileProbe(
152
+ command: string,
153
+ args: readonly string[],
154
+ options: ExecFileProbeOptions,
155
+ ): Promise<{ readonly stdout: string; readonly stderr: string }> {
156
+ const result = await execFileAsync(command, [...args], { timeout: options.timeoutMs });
157
+ return {
158
+ stdout: String(result.stdout),
159
+ stderr: String(result.stderr),
160
+ };
161
+ }
@@ -0,0 +1,37 @@
1
+ import { join } from "node:path";
2
+ import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
3
+ import { SubprocessKernel, type SubprocessSpawn } from "../shared/subprocess-kernel.ts";
4
+
5
+ export interface JuliaKernelStartOptions {
6
+ readonly cwd: string;
7
+ readonly sessionId: string;
8
+ readonly connection: BridgeConnectionConfig;
9
+ readonly command?: string;
10
+ readonly spawn?: SubprocessSpawn;
11
+ readonly onMessage?: (message: KernelToHostMessage) => void;
12
+ }
13
+
14
+ export class JuliaKernel extends SubprocessKernel {
15
+ static start(options: JuliaKernelStartOptions): JuliaKernel {
16
+ return new JuliaKernel({
17
+ command: options.command ?? "julia",
18
+ // --compile=min/--optimize=0 keep the runner in the interpreter with minimal
19
+ // JIT, which does not change cell results but sharply cuts the process's memory
20
+ // and CPU spike at startup — this makes the kernel far less likely to be
21
+ // OOM/CPU-starved (and killed) on an oversubscribed CI runner.
22
+ args: [
23
+ "--startup-file=no",
24
+ "--history-file=no",
25
+ "--color=no",
26
+ "--compile=min",
27
+ "--optimize=0",
28
+ join(import.meta.dirname, "runner.jl"),
29
+ ],
30
+ cwd: options.cwd,
31
+ sessionId: options.sessionId,
32
+ connection: options.connection,
33
+ spawn: options.spawn,
34
+ onMessage: options.onMessage,
35
+ });
36
+ }
37
+ }