@oh-my-pi/pi-coding-agent 16.1.13 → 16.1.14

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 (129) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/cli.js +5547 -2759
  3. package/dist/types/config/settings-schema.d.ts +44 -14
  4. package/dist/types/eval/__tests__/julia-prelude.test.d.ts +1 -0
  5. package/dist/types/eval/backend-helpers.d.ts +23 -0
  6. package/dist/types/eval/executor-base.d.ts +118 -0
  7. package/dist/types/eval/index.d.ts +2 -0
  8. package/dist/types/eval/jl/executor.d.ts +44 -0
  9. package/dist/types/eval/jl/index.d.ts +11 -0
  10. package/dist/types/eval/jl/kernel.d.ts +28 -0
  11. package/dist/types/eval/jl/prelude.d.ts +1 -0
  12. package/dist/types/eval/jl/runtime.d.ts +22 -0
  13. package/dist/types/eval/kernel-base.d.ts +105 -0
  14. package/dist/types/eval/py/kernel.d.ts +3 -61
  15. package/dist/types/eval/rb/executor.d.ts +77 -0
  16. package/dist/types/eval/rb/index.d.ts +11 -0
  17. package/dist/types/eval/rb/kernel.d.ts +31 -0
  18. package/dist/types/eval/rb/prelude.d.ts +1 -0
  19. package/dist/types/eval/rb/runtime.d.ts +23 -0
  20. package/dist/types/eval/runtime-env.d.ts +24 -0
  21. package/dist/types/eval/types.d.ts +3 -3
  22. package/dist/types/extensibility/extensions/runner.d.ts +2 -15
  23. package/dist/types/extensibility/hooks/loader.d.ts +1 -25
  24. package/dist/types/extensibility/session-handler-types.d.ts +23 -0
  25. package/dist/types/jsonrpc/message-framing.d.ts +35 -0
  26. package/dist/types/mnemopi/embed-client.d.ts +7 -24
  27. package/dist/types/modes/components/chat-transcript-builder.d.ts +1 -1
  28. package/dist/types/modes/components/custom-editor.d.ts +11 -0
  29. package/dist/types/modes/components/selector-helpers.d.ts +53 -0
  30. package/dist/types/modes/interactive-mode.d.ts +0 -2
  31. package/dist/types/modes/theme/theme.d.ts +8 -1
  32. package/dist/types/modes/types.d.ts +0 -2
  33. package/dist/types/modes/utils/copy-targets.d.ts +1 -1
  34. package/dist/types/modes/utils/interactive-context-helpers.d.ts +14 -0
  35. package/dist/types/modes/utils/transcript-render-helpers.d.ts +54 -0
  36. package/dist/types/sdk.d.ts +1 -1
  37. package/dist/types/session/agent-session.d.ts +2 -2
  38. package/dist/types/stt/asr-client.d.ts +3 -29
  39. package/dist/types/subprocess/worker-client.d.ts +149 -0
  40. package/dist/types/subprocess/worker-runtime.d.ts +107 -0
  41. package/dist/types/tiny/title-client.d.ts +14 -34
  42. package/dist/types/tools/eval-backends.d.ts +6 -3
  43. package/dist/types/tools/eval-render.d.ts +6 -5
  44. package/dist/types/tools/eval.d.ts +13 -15
  45. package/dist/types/tools/index.d.ts +3 -3
  46. package/dist/types/tts/tts-client.d.ts +3 -28
  47. package/dist/types/tui/code-cell.d.ts +7 -0
  48. package/dist/types/utils/file-display-mode.d.ts +1 -1
  49. package/dist/types/web/parallel.d.ts +6 -0
  50. package/package.json +12 -12
  51. package/src/config/settings-schema.ts +50 -18
  52. package/src/config/settings.ts +5 -0
  53. package/src/dap/client.ts +13 -107
  54. package/src/eval/__tests__/julia-prelude.test.ts +77 -0
  55. package/src/eval/backend-helpers.ts +48 -0
  56. package/src/eval/executor-base.ts +425 -0
  57. package/src/eval/index.ts +2 -0
  58. package/src/eval/jl/executor.ts +540 -0
  59. package/src/eval/jl/index.ts +54 -0
  60. package/src/eval/jl/kernel.ts +235 -0
  61. package/src/eval/jl/prelude.jl +930 -0
  62. package/src/eval/jl/prelude.ts +3 -0
  63. package/src/eval/jl/runner.jl +634 -0
  64. package/src/eval/jl/runtime.ts +118 -0
  65. package/src/eval/js/index.ts +3 -14
  66. package/src/eval/kernel-base.ts +569 -0
  67. package/src/eval/py/executor.ts +43 -252
  68. package/src/eval/py/index.ts +9 -20
  69. package/src/eval/py/kernel.ts +29 -544
  70. package/src/eval/rb/executor.ts +504 -0
  71. package/src/eval/rb/index.ts +54 -0
  72. package/src/eval/rb/kernel.ts +230 -0
  73. package/src/eval/rb/prelude.rb +721 -0
  74. package/src/eval/rb/prelude.ts +3 -0
  75. package/src/eval/rb/runner.rb +474 -0
  76. package/src/eval/rb/runtime.ts +132 -0
  77. package/src/eval/runtime-env.ts +104 -0
  78. package/src/eval/types.ts +3 -3
  79. package/src/extensibility/extensions/runner.ts +4 -11
  80. package/src/extensibility/hooks/loader.ts +3 -21
  81. package/src/extensibility/session-handler-types.ts +21 -0
  82. package/src/internal-urls/docs-index.generated.txt +1 -1
  83. package/src/jsonrpc/message-framing.ts +142 -0
  84. package/src/lsp/client.ts +13 -109
  85. package/src/mnemopi/embed-client.ts +43 -198
  86. package/src/modes/components/agent-dashboard.ts +17 -40
  87. package/src/modes/components/chat-transcript-builder.ts +18 -102
  88. package/src/modes/components/custom-editor.ts +19 -0
  89. package/src/modes/components/extensions/extension-dashboard.ts +4 -13
  90. package/src/modes/components/extensions/extension-list.ts +16 -44
  91. package/src/modes/components/history-search.ts +4 -16
  92. package/src/modes/components/selector-helpers.ts +129 -0
  93. package/src/modes/components/settings-selector.ts +7 -5
  94. package/src/modes/components/tree-selector.ts +13 -18
  95. package/src/modes/controllers/event-controller.ts +3 -9
  96. package/src/modes/controllers/input-controller.ts +32 -54
  97. package/src/modes/interactive-mode.ts +5 -7
  98. package/src/modes/theme/theme.ts +35 -0
  99. package/src/modes/types.ts +0 -2
  100. package/src/modes/utils/copy-targets.ts +3 -2
  101. package/src/modes/utils/interactive-context-helpers.ts +27 -0
  102. package/src/modes/utils/transcript-render-helpers.ts +157 -0
  103. package/src/modes/utils/ui-helpers.ts +21 -126
  104. package/src/prompts/system/system-prompt.md +10 -10
  105. package/src/prompts/tools/bash.md +0 -1
  106. package/src/prompts/tools/eval.md +6 -4
  107. package/src/prompts/tools/find.md +0 -4
  108. package/src/prompts/tools/read.md +1 -2
  109. package/src/prompts/tools/replace.md +1 -1
  110. package/src/prompts/tools/search.md +0 -1
  111. package/src/sdk.ts +13 -7
  112. package/src/session/agent-session.ts +9 -7
  113. package/src/stt/asr-client.ts +35 -215
  114. package/src/stt/asr-worker.ts +29 -181
  115. package/src/subprocess/worker-client.ts +297 -0
  116. package/src/subprocess/worker-runtime.ts +277 -0
  117. package/src/task/executor.ts +4 -4
  118. package/src/tiny/title-client.ts +53 -219
  119. package/src/tiny/worker.ts +29 -180
  120. package/src/tools/eval-backends.ts +10 -3
  121. package/src/tools/eval-render.ts +17 -8
  122. package/src/tools/eval.ts +187 -22
  123. package/src/tools/index.ts +51 -28
  124. package/src/tts/tts-client.ts +38 -206
  125. package/src/tts/tts-worker.ts +23 -97
  126. package/src/tui/code-cell.ts +12 -1
  127. package/src/utils/file-display-mode.ts +2 -3
  128. package/src/web/parallel.ts +43 -42
  129. package/src/web/search/providers/parallel.ts +10 -99
@@ -0,0 +1,504 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
5
+ import type { ToolSession } from "../../tools";
6
+ import {
7
+ attachSessionOwner,
8
+ buildManagedKernelEnv,
9
+ buildManagedKernelEnvPatch,
10
+ createCancelledKernelResult,
11
+ executeWithKernelBase,
12
+ getExecutionDeadlineMs,
13
+ getRemainingTimeoutMs,
14
+ isCancellationError,
15
+ isTimedOutCancellation,
16
+ waitForPromiseWithCancellation,
17
+ } from "../executor-base";
18
+ import type { JsStatusEvent } from "../js/shared/types";
19
+ import { ensurePyToolBridge } from "../py/tool-bridge";
20
+ import {
21
+ checkRubyKernelAvailability,
22
+ type KernelDisplayOutput,
23
+ type KernelExecuteOptions,
24
+ type KernelExecuteResult,
25
+ RubyKernel,
26
+ } from "./kernel";
27
+ import { resolveExplicitRubyRuntime } from "./runtime";
28
+
29
+ export interface RubyExecutorOptions {
30
+ /** Working directory for command execution */
31
+ cwd?: string;
32
+ /** Timeout in milliseconds */
33
+ timeoutMs?: number;
34
+ /** Absolute wall-clock deadline in milliseconds since epoch */
35
+ deadlineMs?: number;
36
+ /**
37
+ * Runtime-work budget (ms). Used only for timeout-annotation text when the
38
+ * caller drives cancellation via the eval watchdog `signal`. Does not arm a timer.
39
+ */
40
+ idleTimeoutMs?: number;
41
+ /** Callback for streaming output chunks (already sanitized) */
42
+ onChunk?: (chunk: string) => Promise<void> | void;
43
+ /** AbortSignal for cancellation */
44
+ signal?: AbortSignal;
45
+ /** Session identifier for kernel reuse */
46
+ sessionId?: string;
47
+ /** Logical owner identifier for retained kernel cleanup */
48
+ kernelOwnerId?: string;
49
+ /** Explicit interpreter path (`ruby.interpreter`). Skips discovery when set. */
50
+ interpreter?: string;
51
+ /** Restart the kernel before executing */
52
+ reset?: boolean;
53
+ /** Session file path for accessing task outputs */
54
+ sessionFile?: string;
55
+ /** Effective artifacts directory for the current session. */
56
+ artifactsDir?: string;
57
+ /** Artifact path/id for full output storage */
58
+ artifactPath?: string;
59
+ artifactId?: string;
60
+ /**
61
+ * On-disk roots the prelude helpers substitute for internal-URL schemes
62
+ * (e.g. `{ local: "/…/artifacts/local" }`). Exported to the kernel as
63
+ * `PI_EVAL_LOCAL_ROOTS` (JSON).
64
+ */
65
+ localRoots?: Record<string, string>;
66
+ /**
67
+ * ToolSession used to resolve host-side `tool.<name>(args)` calls. When
68
+ * omitted, the bridge env vars are not injected and `tool.foo(...)` raises.
69
+ */
70
+ toolSession?: ToolSession;
71
+ /** Callback for status events emitted by tool bridge invocations. */
72
+ emitStatus?: (event: JsStatusEvent) => void;
73
+ /** Live status events streamed as they are emitted. */
74
+ onStatus?: (event: JsStatusEvent) => void;
75
+ /** @internal Bridge session id, set by `executeRuby` before delegating. */
76
+ bridgeSessionId?: string;
77
+ /** @internal Bridge endpoint info, set by `executeRuby` before delegating. */
78
+ bridge?: { url: string; token: string };
79
+ }
80
+
81
+ export interface RubyKernelExecutor {
82
+ execute: (code: string, options?: KernelExecuteOptions) => Promise<KernelExecuteResult>;
83
+ }
84
+
85
+ export interface RubyResult {
86
+ output: string;
87
+ exitCode: number | undefined;
88
+ cancelled: boolean;
89
+ truncated: boolean;
90
+ artifactId?: string;
91
+ totalLines: number;
92
+ totalBytes: number;
93
+ outputLines: number;
94
+ outputBytes: number;
95
+ displayOutputs: KernelDisplayOutput[];
96
+ stdinRequested: boolean;
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Session bookkeeping
101
+ //
102
+ // One RubyKernel subprocess per (session id, cwd, interpreter) tuple. The
103
+ // runner mutates process-global cwd/$LOAD_PATH/ENV during execution, so
104
+ // cross-directory work must never share a live kernel. Multiple agent owners can
105
+ // register against the same tuple; the kernel stays alive until the last owner detaches.
106
+ // ---------------------------------------------------------------------------
107
+
108
+ interface RubySessionOwners {
109
+ ownerIds: Set<string>;
110
+ hasFallbackOwner: boolean;
111
+ }
112
+
113
+ interface RubySession extends RubySessionOwners {
114
+ sessionKey: string;
115
+ sessionId: string;
116
+ cwd: string;
117
+ kernel: RubyKernel;
118
+ }
119
+
120
+ interface StartingRubySession extends RubySessionOwners {
121
+ promise: Promise<RubySession>;
122
+ }
123
+
124
+ const sessions = new Map<string, RubySession>();
125
+ const startingSessions = new Map<string, StartingRubySession>();
126
+ const resettingSessions = new Map<string, Promise<void>>();
127
+
128
+ function normalizeSessionCwd(cwd: string): string {
129
+ return path.resolve(cwd);
130
+ }
131
+
132
+ function normalizeExplicitInterpreter(cwd: string, interpreter: string | undefined): string {
133
+ if (interpreter === undefined) return "";
134
+ const resolved = resolveExplicitRubyRuntime(interpreter, cwd, {}).rubyPath;
135
+ try {
136
+ return fs.realpathSync.native(resolved);
137
+ } catch {
138
+ return resolved;
139
+ }
140
+ }
141
+
142
+ function buildSessionKey(sessionId: string, cwd: string, interpreter: string | undefined): string {
143
+ const normalizedCwd = normalizeSessionCwd(cwd);
144
+ return `${sessionId}\0${normalizedCwd}\0${normalizeExplicitInterpreter(normalizedCwd, interpreter)}`;
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Cancellation plumbing
149
+ // ---------------------------------------------------------------------------
150
+
151
+ class RubyExecutionCancelledError extends Error {
152
+ readonly timedOut: boolean;
153
+
154
+ constructor(timedOut: boolean) {
155
+ super(timedOut ? "Command timed out" : "Command aborted");
156
+ this.name = timedOut ? "TimeoutError" : "AbortError";
157
+ this.timedOut = timedOut;
158
+ }
159
+ }
160
+
161
+ function requireRemainingTimeoutMs(deadlineMs?: number): number | undefined {
162
+ const remainingMs = getRemainingTimeoutMs(deadlineMs);
163
+ if (remainingMs === undefined) return undefined;
164
+ if (remainingMs <= 0) {
165
+ throw new RubyExecutionCancelledError(true);
166
+ }
167
+ return remainingMs;
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Result formatting
172
+ // ---------------------------------------------------------------------------
173
+
174
+ function formatTimeoutAnnotation(timeoutMs?: number): string | undefined {
175
+ if (timeoutMs === undefined) return "Command timed out";
176
+ const secs = Math.max(1, Math.round(timeoutMs / 1000));
177
+ return `Command timed out after ${secs} seconds`;
178
+ }
179
+
180
+ function formatKernelTimeoutAnnotation(timeoutMs: number | undefined, kernelKilled: boolean): string {
181
+ const secs = timeoutMs === undefined ? undefined : Math.max(1, Math.round(timeoutMs / 1000));
182
+ if (kernelKilled) {
183
+ return "eval cell timed out and the kernel was unresponsive to interrupt; the kernel has been killed and will be recreated on the next call.";
184
+ }
185
+ const duration = secs === undefined ? "the configured timeout" : `${secs}s`;
186
+ return `eval cell timed out after ${duration}; kernel interrupted but remains running. Reset the kernel via { reset: true } if state appears corrupted.`;
187
+ }
188
+
189
+ function createCancelledRubyResult(timedOut: boolean, timeoutMs?: number): RubyResult {
190
+ const output = timedOut ? (formatTimeoutAnnotation(timeoutMs) ?? "Command timed out") : "";
191
+ return createCancelledKernelResult(output);
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Kernel start helpers
196
+ // ---------------------------------------------------------------------------
197
+
198
+ async function startKernel(cwd: string, options: RubyExecutorOptions): Promise<RubyKernel> {
199
+ requireRemainingTimeoutMs(options.deadlineMs);
200
+ return await RubyKernel.start({
201
+ cwd,
202
+ env: buildManagedKernelEnv(options),
203
+ signal: options.signal,
204
+ deadlineMs: options.deadlineMs,
205
+ interpreter: options.interpreter,
206
+ });
207
+ }
208
+
209
+ async function acquireSession(
210
+ sessionKey: string,
211
+ sessionId: string,
212
+ cwd: string,
213
+ options: RubyExecutorOptions,
214
+ ): Promise<RubySession> {
215
+ const existing = sessions.get(sessionKey);
216
+ if (existing) {
217
+ attachSessionOwner(existing, sessionId, options.kernelOwnerId);
218
+ return existing;
219
+ }
220
+ const starting = startingSessions.get(sessionKey);
221
+ if (starting) {
222
+ attachSessionOwner(starting, sessionId, options.kernelOwnerId);
223
+ return await starting.promise;
224
+ }
225
+ let startingSession!: StartingRubySession;
226
+ const startup = (async () => {
227
+ const kernel = await startKernel(cwd, options);
228
+ const session: RubySession = {
229
+ sessionKey,
230
+ sessionId,
231
+ cwd,
232
+ kernel,
233
+ ownerIds: new Set(startingSession.ownerIds),
234
+ hasFallbackOwner: startingSession.hasFallbackOwner,
235
+ };
236
+ if (startingSessions.get(sessionKey) === startingSession) {
237
+ sessions.set(sessionKey, session);
238
+ }
239
+ return session;
240
+ })();
241
+ startingSession = {
242
+ ownerIds: new Set(),
243
+ hasFallbackOwner: false,
244
+ promise: startup,
245
+ };
246
+ attachSessionOwner(startingSession, sessionId, options.kernelOwnerId);
247
+ startingSessions.set(sessionKey, startingSession);
248
+ try {
249
+ return await startup;
250
+ } finally {
251
+ if (startingSessions.get(sessionKey) === startingSession) startingSessions.delete(sessionKey);
252
+ }
253
+ }
254
+
255
+ async function replaceSessionKernel(session: RubySession, cwd: string, options: RubyExecutorOptions): Promise<void> {
256
+ const old = session.kernel;
257
+ const remaining = getRemainingTimeoutMs(options.deadlineMs);
258
+ await old
259
+ .shutdown(remaining !== undefined ? { timeoutMs: Math.max(0, remaining) } : undefined)
260
+ .catch(() => undefined);
261
+ if (sessions.get(session.sessionKey) !== session) {
262
+ throw new RubyExecutionCancelledError(false);
263
+ }
264
+ requireRemainingTimeoutMs(options.deadlineMs);
265
+ const next = await startKernel(cwd, options);
266
+ if (sessions.get(session.sessionKey) !== session) {
267
+ await next.shutdown().catch(() => undefined);
268
+ throw new RubyExecutionCancelledError(false);
269
+ }
270
+ session.kernel = next;
271
+ }
272
+
273
+ async function resetSession(sessionKey: string): Promise<void> {
274
+ const existing =
275
+ sessions.get(sessionKey) ?? (await startingSessions.get(sessionKey)?.promise.catch(() => undefined));
276
+ if (!existing) return;
277
+ sessions.delete(sessionKey);
278
+ await existing.kernel.shutdown().catch(() => undefined);
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // Public dispose entry points
283
+ // ---------------------------------------------------------------------------
284
+
285
+ export async function disposeAllRubyKernelSessions(): Promise<void> {
286
+ const pending = [...startingSessions.values()].map(starting => starting.promise);
287
+ startingSessions.clear();
288
+ const started = await Promise.allSettled(pending);
289
+ const all = [...sessions.entries()];
290
+ for (const result of started) {
291
+ if (result.status !== "fulfilled") continue;
292
+ if (!all.some(([, session]) => session === result.value)) {
293
+ all.push([result.value.sessionKey, result.value]);
294
+ }
295
+ }
296
+ for (const [id, session] of all) {
297
+ if (sessions.get(id) === session) sessions.delete(id);
298
+ }
299
+ const results = await Promise.allSettled(all.map(([, session]) => session.kernel.shutdown()));
300
+ for (let i = 0; i < all.length; i += 1) {
301
+ const [id, session] = all[i];
302
+ const result = results[i];
303
+ if (result.status === "fulfilled" && result.value?.confirmed !== false) continue;
304
+ const reason = result.status === "rejected" ? result.reason : "not confirmed";
305
+ logger.warn("Ruby kernel shutdown not confirmed", {
306
+ sessionId: session.sessionId,
307
+ sessionKey: id,
308
+ cwd: session.cwd,
309
+ reason,
310
+ });
311
+ if (!sessions.has(id)) sessions.set(id, session);
312
+ }
313
+ }
314
+
315
+ export async function disposeRubyKernelSessionsByOwner(ownerId: string): Promise<void> {
316
+ const toShutdown: RubySession[] = [];
317
+ const startingToShutdown: StartingRubySession[] = [];
318
+ for (const session of [...sessions.values()]) {
319
+ if (!session.ownerIds.has(ownerId)) continue;
320
+ if (session.ownerIds.size === 1) {
321
+ toShutdown.push(session);
322
+ continue;
323
+ }
324
+ session.ownerIds.delete(ownerId);
325
+ }
326
+ for (const [sessionKey, starting] of [...startingSessions.entries()]) {
327
+ if (sessions.has(sessionKey) || !starting.ownerIds.has(ownerId)) continue;
328
+ if (starting.ownerIds.size === 1) {
329
+ startingSessions.delete(sessionKey);
330
+ startingToShutdown.push(starting);
331
+ continue;
332
+ }
333
+ starting.ownerIds.delete(ownerId);
334
+ }
335
+ for (const session of toShutdown) {
336
+ if (sessions.get(session.sessionKey) === session) sessions.delete(session.sessionKey);
337
+ }
338
+ const started = await Promise.allSettled(startingToShutdown.map(starting => starting.promise));
339
+ for (const result of started) {
340
+ if (result.status !== "fulfilled") continue;
341
+ const session = result.value;
342
+ if (sessions.get(session.sessionKey) === session) sessions.delete(session.sessionKey);
343
+ toShutdown.push(session);
344
+ }
345
+ const results = await Promise.allSettled(toShutdown.map(session => session.kernel.shutdown()));
346
+ for (let i = 0; i < toShutdown.length; i += 1) {
347
+ const session = toShutdown[i];
348
+ const result = results[i];
349
+ if (result.status === "fulfilled" && result.value?.confirmed !== false) {
350
+ session.ownerIds.delete(ownerId);
351
+ continue;
352
+ }
353
+ const reason = result.status === "rejected" ? result.reason : "not confirmed";
354
+ logger.warn("Ruby kernel shutdown not confirmed", {
355
+ sessionId: session.sessionId,
356
+ sessionKey: session.sessionKey,
357
+ cwd: session.cwd,
358
+ reason,
359
+ });
360
+ if (!sessions.has(session.sessionKey)) sessions.set(session.sessionKey, session);
361
+ }
362
+ }
363
+
364
+ // ---------------------------------------------------------------------------
365
+ // Execution
366
+ // ---------------------------------------------------------------------------
367
+
368
+ async function executeWithKernel(
369
+ kernel: RubyKernelExecutor,
370
+ code: string,
371
+ options: RubyExecutorOptions | undefined,
372
+ ): Promise<RubyResult> {
373
+ return executeWithKernelBase<RubyExecutorOptions>({
374
+ kernel,
375
+ code,
376
+ options,
377
+ runIdPrefix: "rb",
378
+ errorLogLabel: "Ruby",
379
+ cancelledErrorClass: RubyExecutionCancelledError,
380
+ buildKernelEnvPatch: buildManagedKernelEnvPatch,
381
+ formatKernelTimeoutAnnotation,
382
+ formatTimeoutAnnotation,
383
+ });
384
+ }
385
+
386
+ async function ensureKernelAvailable(cwd: string, options: RubyExecutorOptions): Promise<void> {
387
+ const availability = await waitForPromiseWithCancellation(
388
+ checkRubyKernelAvailability(cwd, options.interpreter),
389
+ options,
390
+ RubyExecutionCancelledError,
391
+ );
392
+ if (!availability.ok) {
393
+ throw new Error(availability.reason ?? "Ruby kernel unavailable");
394
+ }
395
+ }
396
+
397
+ async function ensureToolBridge(options: RubyExecutorOptions): Promise<void> {
398
+ if (!options.toolSession || options.bridge) return;
399
+ try {
400
+ options.bridge = await ensurePyToolBridge();
401
+ } catch (err) {
402
+ logger.warn("Failed to start Ruby tool bridge", {
403
+ error: err instanceof Error ? err.message : String(err),
404
+ });
405
+ }
406
+ }
407
+
408
+ async function executeOnSession(code: string, cwd: string, options: RubyExecutorOptions): Promise<RubyResult> {
409
+ const sessionId = options.sessionId ?? `session:${cwd}`;
410
+ const sessionKey = buildSessionKey(sessionId, cwd, options.interpreter);
411
+ if (options.bridge && !options.bridgeSessionId) {
412
+ options.bridgeSessionId = sessionId;
413
+ }
414
+ if (options.reset) {
415
+ const inFlight = resettingSessions.get(sessionKey);
416
+ if (inFlight) await inFlight.catch(() => undefined);
417
+ else {
418
+ const resetPromise = resetSession(sessionKey);
419
+ resettingSessions.set(
420
+ sessionKey,
421
+ resetPromise.then(() => undefined),
422
+ );
423
+ try {
424
+ await resetPromise;
425
+ } finally {
426
+ resettingSessions.delete(sessionKey);
427
+ }
428
+ }
429
+ } else {
430
+ const inFlight = resettingSessions.get(sessionKey);
431
+ if (inFlight) await inFlight.catch(() => undefined);
432
+ }
433
+ const session = await acquireSession(sessionKey, sessionId, cwd, options);
434
+ if (options.signal?.aborted) {
435
+ throw new RubyExecutionCancelledError(
436
+ isTimedOutCancellation(options.signal.reason, RubyExecutionCancelledError, options.signal),
437
+ );
438
+ }
439
+ if (sessions.get(session.sessionKey) !== session) {
440
+ throw new RubyExecutionCancelledError(false);
441
+ }
442
+ if (!session.kernel.isAlive()) {
443
+ await replaceSessionKernel(session, cwd, options);
444
+ if (sessions.get(session.sessionKey) !== session) {
445
+ throw new RubyExecutionCancelledError(false);
446
+ }
447
+ }
448
+ const runOptions = { ...options, cwd };
449
+ try {
450
+ return await executeWithKernel(session.kernel, code, runOptions);
451
+ } catch (err) {
452
+ if (isCancellationError(err, RubyExecutionCancelledError) || options.signal?.aborted) throw err;
453
+ if (session.kernel.isAlive()) throw err;
454
+ if (sessions.get(session.sessionKey) !== session) {
455
+ throw new RubyExecutionCancelledError(false);
456
+ }
457
+ await replaceSessionKernel(session, cwd, options);
458
+ if (sessions.get(session.sessionKey) !== session) {
459
+ throw new RubyExecutionCancelledError(false);
460
+ }
461
+ return await executeWithKernel(session.kernel, code, runOptions);
462
+ }
463
+ }
464
+
465
+ export async function executeRubyWithKernel(
466
+ kernel: RubyKernelExecutor,
467
+ code: string,
468
+ options?: RubyExecutorOptions,
469
+ ): Promise<RubyResult> {
470
+ return await executeWithKernel(kernel, code, options);
471
+ }
472
+
473
+ export async function executeRuby(code: string, options?: RubyExecutorOptions): Promise<RubyResult> {
474
+ const cwd = normalizeSessionCwd(options?.cwd ?? getProjectDir());
475
+ const deadlineMs = getExecutionDeadlineMs(options);
476
+ const executionOptions: RubyExecutorOptions = {
477
+ ...(options ?? {}),
478
+ cwd,
479
+ deadlineMs,
480
+ };
481
+
482
+ try {
483
+ requireRemainingTimeoutMs(deadlineMs);
484
+ if (executionOptions.signal?.aborted) {
485
+ throw new RubyExecutionCancelledError(
486
+ isTimedOutCancellation(
487
+ executionOptions.signal.reason,
488
+ RubyExecutionCancelledError,
489
+ executionOptions.signal,
490
+ ),
491
+ );
492
+ }
493
+ await ensureKernelAvailable(cwd, executionOptions);
494
+ await ensureToolBridge(executionOptions);
495
+ return await executeOnSession(code, cwd, executionOptions);
496
+ } catch (err) {
497
+ if (isCancellationError(err, RubyExecutionCancelledError) || executionOptions.signal?.aborted) {
498
+ return createCancelledRubyResult(
499
+ isTimedOutCancellation(err, RubyExecutionCancelledError, executionOptions.signal),
500
+ );
501
+ }
502
+ throw err;
503
+ }
504
+ }
@@ -0,0 +1,54 @@
1
+ import type { ToolSession } from "../../tools";
2
+ import {
3
+ type ExecutorBackend,
4
+ type ExecutorBackendExecOptions,
5
+ type ExecutorBackendResult,
6
+ resolveEvalUrlRoots,
7
+ } from "../backend";
8
+ import {
9
+ namespaceSessionId as sharedNamespace,
10
+ readInterpreterSetting as sharedReadInterpreterSetting,
11
+ toExecutorBackendResult,
12
+ } from "../backend-helpers";
13
+ import { executeRuby } from "./executor";
14
+ import { checkRubyKernelAvailability } from "./kernel";
15
+
16
+ const RUBY_SESSION_PREFIX = "ruby:";
17
+
18
+ export function namespaceSessionId(sessionId: string): string {
19
+ return sharedNamespace(sessionId, RUBY_SESSION_PREFIX);
20
+ }
21
+
22
+ function readInterpreterSetting(session: ToolSession): string | undefined {
23
+ return sharedReadInterpreterSetting(session, "ruby.interpreter");
24
+ }
25
+
26
+ export default {
27
+ id: "ruby",
28
+ label: "Ruby",
29
+ highlightLang: "ruby",
30
+
31
+ async isAvailable(session: ToolSession): Promise<boolean> {
32
+ const availability = await checkRubyKernelAvailability(session.cwd, readInterpreterSetting(session));
33
+ return availability.ok;
34
+ },
35
+
36
+ async execute(code: string, opts: ExecutorBackendExecOptions): Promise<ExecutorBackendResult> {
37
+ const result = await executeRuby(code, {
38
+ cwd: opts.cwd,
39
+ idleTimeoutMs: opts.idleTimeoutMs,
40
+ signal: opts.signal,
41
+ sessionId: namespaceSessionId(opts.sessionId),
42
+ interpreter: readInterpreterSetting(opts.session),
43
+ sessionFile: opts.sessionFile,
44
+ artifactsDir: opts.session.getArtifactsDir?.() ?? undefined,
45
+ localRoots: resolveEvalUrlRoots(opts.session),
46
+ kernelOwnerId: opts.kernelOwnerId,
47
+ reset: opts.reset,
48
+ onChunk: opts.onChunk,
49
+ onStatus: opts.onStatus,
50
+ toolSession: opts.session,
51
+ });
52
+ return toExecutorBackendResult(result);
53
+ },
54
+ } satisfies ExecutorBackend;