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

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 (153) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/cli.js +5615 -2833
  3. package/dist/types/advisor/runtime.d.ts +3 -0
  4. package/dist/types/config/settings-schema.d.ts +64 -14
  5. package/dist/types/eval/__tests__/julia-prelude.test.d.ts +1 -0
  6. package/dist/types/eval/backend-helpers.d.ts +23 -0
  7. package/dist/types/eval/executor-base.d.ts +118 -0
  8. package/dist/types/eval/index.d.ts +2 -0
  9. package/dist/types/eval/jl/executor.d.ts +44 -0
  10. package/dist/types/eval/jl/index.d.ts +11 -0
  11. package/dist/types/eval/jl/kernel.d.ts +28 -0
  12. package/dist/types/eval/jl/prelude.d.ts +1 -0
  13. package/dist/types/eval/jl/runtime.d.ts +22 -0
  14. package/dist/types/eval/kernel-base.d.ts +105 -0
  15. package/dist/types/eval/py/kernel.d.ts +3 -61
  16. package/dist/types/eval/rb/executor.d.ts +77 -0
  17. package/dist/types/eval/rb/index.d.ts +11 -0
  18. package/dist/types/eval/rb/kernel.d.ts +31 -0
  19. package/dist/types/eval/rb/prelude.d.ts +1 -0
  20. package/dist/types/eval/rb/runtime.d.ts +23 -0
  21. package/dist/types/eval/runtime-env.d.ts +24 -0
  22. package/dist/types/eval/types.d.ts +3 -3
  23. package/dist/types/export/share.d.ts +8 -1
  24. package/dist/types/extensibility/extensions/runner.d.ts +2 -15
  25. package/dist/types/extensibility/hooks/loader.d.ts +1 -25
  26. package/dist/types/extensibility/session-handler-types.d.ts +23 -0
  27. package/dist/types/jsonrpc/message-framing.d.ts +35 -0
  28. package/dist/types/mcp/transports/stdio.d.ts +12 -1
  29. package/dist/types/mnemopi/embed-client.d.ts +7 -24
  30. package/dist/types/modes/components/chat-transcript-builder.d.ts +1 -1
  31. package/dist/types/modes/components/custom-editor.d.ts +11 -0
  32. package/dist/types/modes/components/selector-helpers.d.ts +53 -0
  33. package/dist/types/modes/components/status-line/context-thresholds.d.ts +4 -3
  34. package/dist/types/modes/components/status-line/types.d.ts +1 -0
  35. package/dist/types/modes/interactive-mode.d.ts +0 -2
  36. package/dist/types/modes/theme/theme.d.ts +8 -1
  37. package/dist/types/modes/types.d.ts +0 -2
  38. package/dist/types/modes/utils/copy-targets.d.ts +1 -1
  39. package/dist/types/modes/utils/interactive-context-helpers.d.ts +14 -0
  40. package/dist/types/modes/utils/transcript-render-helpers.d.ts +54 -0
  41. package/dist/types/sdk.d.ts +1 -1
  42. package/dist/types/secrets/obfuscator.d.ts +3 -3
  43. package/dist/types/session/agent-session.d.ts +2 -2
  44. package/dist/types/stt/asr-client.d.ts +3 -29
  45. package/dist/types/subprocess/worker-client.d.ts +149 -0
  46. package/dist/types/subprocess/worker-runtime.d.ts +107 -0
  47. package/dist/types/tiny/title-client.d.ts +14 -34
  48. package/dist/types/tools/eval-backends.d.ts +6 -3
  49. package/dist/types/tools/eval-render.d.ts +6 -5
  50. package/dist/types/tools/eval.d.ts +13 -15
  51. package/dist/types/tools/index.d.ts +3 -3
  52. package/dist/types/tts/tts-client.d.ts +3 -28
  53. package/dist/types/tui/code-cell.d.ts +7 -0
  54. package/dist/types/utils/file-display-mode.d.ts +1 -1
  55. package/dist/types/utils/shell-snapshot.d.ts +10 -0
  56. package/dist/types/web/parallel.d.ts +6 -0
  57. package/dist/types/web/search/providers/perplexity.d.ts +17 -3
  58. package/package.json +12 -12
  59. package/src/advisor/__tests__/advisor.test.ts +114 -0
  60. package/src/advisor/runtime.ts +129 -1
  61. package/src/config/model-registry.ts +12 -4
  62. package/src/config/settings-schema.ts +74 -18
  63. package/src/config/settings.ts +5 -0
  64. package/src/dap/client.ts +13 -107
  65. package/src/eval/__tests__/julia-prelude.test.ts +77 -0
  66. package/src/eval/backend-helpers.ts +48 -0
  67. package/src/eval/executor-base.ts +425 -0
  68. package/src/eval/index.ts +2 -0
  69. package/src/eval/jl/executor.ts +540 -0
  70. package/src/eval/jl/index.ts +54 -0
  71. package/src/eval/jl/kernel.ts +235 -0
  72. package/src/eval/jl/prelude.jl +930 -0
  73. package/src/eval/jl/prelude.ts +3 -0
  74. package/src/eval/jl/runner.jl +634 -0
  75. package/src/eval/jl/runtime.ts +118 -0
  76. package/src/eval/js/index.ts +3 -14
  77. package/src/eval/kernel-base.ts +569 -0
  78. package/src/eval/py/executor.ts +43 -252
  79. package/src/eval/py/index.ts +9 -20
  80. package/src/eval/py/kernel.ts +29 -544
  81. package/src/eval/rb/executor.ts +504 -0
  82. package/src/eval/rb/index.ts +54 -0
  83. package/src/eval/rb/kernel.ts +230 -0
  84. package/src/eval/rb/prelude.rb +721 -0
  85. package/src/eval/rb/prelude.ts +3 -0
  86. package/src/eval/rb/runner.rb +474 -0
  87. package/src/eval/rb/runtime.ts +132 -0
  88. package/src/eval/runtime-env.ts +104 -0
  89. package/src/eval/types.ts +3 -3
  90. package/src/exec/bash-executor.ts +44 -0
  91. package/src/export/share.ts +51 -28
  92. package/src/extensibility/extensions/runner.ts +4 -11
  93. package/src/extensibility/hooks/loader.ts +3 -21
  94. package/src/extensibility/session-handler-types.ts +21 -0
  95. package/src/internal-urls/docs-index.generated.txt +1 -1
  96. package/src/jsonrpc/message-framing.ts +142 -0
  97. package/src/lsp/client.ts +13 -109
  98. package/src/mcp/transports/stdio.ts +20 -4
  99. package/src/mnemopi/embed-client.ts +43 -198
  100. package/src/modes/components/agent-dashboard.ts +17 -40
  101. package/src/modes/components/chat-transcript-builder.ts +18 -102
  102. package/src/modes/components/custom-editor.test.ts +22 -0
  103. package/src/modes/components/custom-editor.ts +29 -1
  104. package/src/modes/components/extensions/extension-dashboard.ts +4 -13
  105. package/src/modes/components/extensions/extension-list.ts +16 -44
  106. package/src/modes/components/footer.ts +4 -3
  107. package/src/modes/components/history-search.ts +4 -16
  108. package/src/modes/components/selector-helpers.ts +129 -0
  109. package/src/modes/components/settings-selector.ts +7 -5
  110. package/src/modes/components/status-line/component.ts +5 -1
  111. package/src/modes/components/status-line/context-thresholds.ts +11 -3
  112. package/src/modes/components/status-line/segments.ts +1 -1
  113. package/src/modes/components/status-line/types.ts +1 -0
  114. package/src/modes/components/tree-selector.ts +13 -18
  115. package/src/modes/controllers/command-controller.ts +1 -0
  116. package/src/modes/controllers/event-controller.ts +3 -9
  117. package/src/modes/controllers/input-controller.ts +32 -54
  118. package/src/modes/interactive-mode.ts +5 -7
  119. package/src/modes/theme/theme.ts +35 -0
  120. package/src/modes/types.ts +0 -2
  121. package/src/modes/utils/copy-targets.ts +3 -2
  122. package/src/modes/utils/interactive-context-helpers.ts +27 -0
  123. package/src/modes/utils/transcript-render-helpers.ts +157 -0
  124. package/src/modes/utils/ui-helpers.ts +21 -126
  125. package/src/prompts/system/system-prompt.md +5 -5
  126. package/src/prompts/tools/bash.md +2 -3
  127. package/src/prompts/tools/eval.md +6 -4
  128. package/src/prompts/tools/find.md +0 -4
  129. package/src/prompts/tools/read.md +1 -2
  130. package/src/prompts/tools/replace.md +1 -1
  131. package/src/sdk.ts +13 -7
  132. package/src/secrets/obfuscator.ts +3 -9
  133. package/src/session/agent-session.ts +42 -9
  134. package/src/slash-commands/builtin-registry.ts +2 -1
  135. package/src/stt/asr-client.ts +35 -215
  136. package/src/stt/asr-worker.ts +29 -181
  137. package/src/subprocess/worker-client.ts +297 -0
  138. package/src/subprocess/worker-runtime.ts +277 -0
  139. package/src/task/executor.ts +4 -4
  140. package/src/tiny/title-client.ts +53 -219
  141. package/src/tiny/worker.ts +29 -180
  142. package/src/tools/eval-backends.ts +10 -3
  143. package/src/tools/eval-render.ts +17 -8
  144. package/src/tools/eval.ts +187 -22
  145. package/src/tools/index.ts +51 -28
  146. package/src/tts/tts-client.ts +38 -206
  147. package/src/tts/tts-worker.ts +23 -97
  148. package/src/tui/code-cell.ts +12 -1
  149. package/src/utils/file-display-mode.ts +2 -3
  150. package/src/utils/shell-snapshot.ts +63 -1
  151. package/src/web/parallel.ts +43 -42
  152. package/src/web/search/providers/parallel.ts +10 -99
  153. package/src/web/search/providers/perplexity.ts +18 -6
@@ -10,14 +10,23 @@ import type {
10
10
  import {
11
11
  ensureRuntimeInstalled,
12
12
  getTinyModelsCacheDir,
13
- installRuntimeModuleResolver,
14
13
  isCompiledBinary,
15
14
  resolveRuntimeModule,
16
15
  } from "@oh-my-pi/pi-utils";
17
16
  import packageJson from "../../package.json" with { type: "json" };
17
+ import {
18
+ errorMessage,
19
+ errorText,
20
+ getTransformersVersionSpec,
21
+ loadTransformersRuntime,
22
+ MemoizedRuntime,
23
+ replayCachedReady,
24
+ sendLog,
25
+ sendProgress,
26
+ } from "../subprocess/worker-runtime";
18
27
  import { resolveTinyModelDevicePreference, type TinyModelDevice, tinyModelDeviceLoadOrder } from "../tiny/device";
19
28
  import { resolveTinyModelDtypeOverride, type TinyModelDtype } from "../tiny/dtype";
20
- import type { SttProgressEvent, SttTransport, SttWorkerInbound } from "./asr-protocol";
29
+ import type { SttTransport, SttWorkerInbound } from "./asr-protocol";
21
30
  import { type EndpointerEvent, StreamEndpointer } from "./endpointer";
22
31
  import {
23
32
  getSttModelSpec,
@@ -28,9 +37,7 @@ import {
28
37
  } from "./models";
29
38
 
30
39
  const ASR_TASK = "automatic-speech-recognition";
31
- const TRANSFORMERS_PACKAGE = "@huggingface/transformers";
32
40
  const SHERPA_PACKAGE = "sherpa-onnx-node";
33
- const COMPILED_TRANSFORMERS_VERSION = process.env.PI_TINY_TRANSFORMERS_VERSION;
34
41
  // Whisper long-form decoding: split into 30s windows with 5s overlap so audio of
35
42
  // any length transcribes without exceeding the 30s receptive field.
36
43
  const CHUNK_LENGTH_S = 30;
@@ -136,34 +143,8 @@ function runOnModel<T>(work: () => Promise<T>): Promise<T> {
136
143
  );
137
144
  return run;
138
145
  }
139
- let transformersRuntime: Promise<TransformersRuntime> | null = null;
140
- let sherpaRuntime: Promise<SherpaRuntime> | null = null;
141
-
142
- let cachedTransformersVersionSpec: string | undefined;
143
- function resolveTransformersVersionSpec(): string {
144
- const manifest = packageJson as {
145
- optionalDependencies?: Record<string, string>;
146
- dependencies?: Record<string, string>;
147
- };
148
- const versionSpec =
149
- manifest.optionalDependencies?.[TRANSFORMERS_PACKAGE] ?? manifest.dependencies?.[TRANSFORMERS_PACKAGE];
150
- if (!versionSpec) throw new Error(`${TRANSFORMERS_PACKAGE} is missing from package.json optionalDependencies`);
151
- if (!versionSpec.startsWith("catalog:")) return versionSpec;
152
- if (COMPILED_TRANSFORMERS_VERSION) return COMPILED_TRANSFORMERS_VERSION;
153
- const installed = sourceRequire(`${TRANSFORMERS_PACKAGE}/package.json`) as { version: string };
154
- return installed.version;
155
- }
156
-
157
- /**
158
- * Lazily resolve (and memoize) the transformers version spec. In the `catalog:`
159
- * case this `require`s the installed package manifest, so defer it to the
160
- * compiled-binary runtime-install path (only reached on a real transcribe /
161
- * download) — loading this worker for a smoke ping never triggers the resolve.
162
- */
163
- function getTransformersVersionSpec(): string {
164
- cachedTransformersVersionSpec ??= resolveTransformersVersionSpec();
165
- return cachedTransformersVersionSpec;
166
- }
146
+ const transformersRuntime = new MemoizedRuntime<TransformersRuntime>();
147
+ const sherpaRuntime = new MemoizedRuntime<SherpaRuntime>();
167
148
 
168
149
  let cachedSherpaVersionSpec: string | undefined;
169
150
  function resolveSherpaVersionSpec(): string {
@@ -181,23 +162,6 @@ function getSherpaVersionSpec(): string {
181
162
  return cachedSherpaVersionSpec;
182
163
  }
183
164
 
184
- function errorText(error: unknown): string {
185
- return error instanceof Error ? (error.stack ?? error.message) : String(error);
186
- }
187
-
188
- function errorMessage(error: unknown): string {
189
- return error instanceof Error ? error.message : String(error);
190
- }
191
-
192
- function sendLog(
193
- transport: SttTransport,
194
- level: "debug" | "warn" | "error",
195
- msg: string,
196
- meta?: Record<string, unknown>,
197
- ): void {
198
- transport.send({ type: "log", level, msg, meta });
199
- }
200
-
201
165
  function getSttRuntimeDir(): string {
202
166
  const key = getTransformersVersionSpec().replace(/[^A-Za-z0-9._-]/g, "_");
203
167
  return path.join(path.dirname(getTinyModelsCacheDir()), "stt-runtime", `transformers-${key}`);
@@ -208,73 +172,6 @@ function getSherpaRuntimeDir(): string {
208
172
  return path.join(path.dirname(getTinyModelsCacheDir()), "stt-runtime", `sherpa-${key}`);
209
173
  }
210
174
 
211
- function sendRuntimeInstallProgress(
212
- transport: SttTransport,
213
- requestId: string,
214
- modelKey: SttModelKey,
215
- status: "initiate" | "download" | "done",
216
- name: string,
217
- ): void {
218
- transport.send({ type: "progress", id: requestId, event: { modelKey, status, name } });
219
- }
220
-
221
- /**
222
- * Prepare the freshly-installed compiled runtime for loading: stub `sharp` (the
223
- * speech pipeline is audio-only, so the native image codec is dead weight) and
224
- * patch the module resolver so Transformers.js's bare requires resolve against
225
- * the cache. Returns the absolute Transformers.js entrypoint to `require`.
226
- */
227
- async function prepareCompiledRuntime(runtimeDir: string): Promise<string> {
228
- const nodeModules = path.join(runtimeDir, "node_modules");
229
- const sharpStub = path.join(runtimeDir, "omp-sharp-stub.cjs");
230
- await Bun.write(sharpStub, "module.exports = {};\n");
231
- installRuntimeModuleResolver({ runtimeNodeModules: nodeModules, stubs: { sharp: sharpStub } });
232
- const entry = resolveRuntimeModule(nodeModules, TRANSFORMERS_PACKAGE);
233
- if (!entry) throw new Error(`Unable to resolve ${TRANSFORMERS_PACKAGE} in compiled runtime at ${nodeModules}`);
234
- return entry;
235
- }
236
-
237
- function configureTransformers(transformers: TransformersRuntime): TransformersRuntime {
238
- transformers.env.cacheDir = getTinyModelsCacheDir();
239
- transformers.env.allowLocalModels = false;
240
- transformers.env.logLevel = transformers.LogLevel.ERROR;
241
- return transformers;
242
- }
243
-
244
- async function loadTransformers(
245
- transport: SttTransport,
246
- requestId: string,
247
- modelKey: SttModelKey,
248
- ): Promise<TransformersRuntime> {
249
- if (transformersRuntime) return transformersRuntime;
250
- transformersRuntime = (async () => {
251
- if (!isCompiledBinary()) return configureTransformers(sourceRequire(TRANSFORMERS_PACKAGE) as TransformersRuntime);
252
- const runtimeDir = await ensureRuntimeInstalled({
253
- runtimeDir: getSttRuntimeDir(),
254
- install: {
255
- dependencies: { [TRANSFORMERS_PACKAGE]: getTransformersVersionSpec() },
256
- trustedDependencies: ["onnxruntime-node"],
257
- },
258
- probePackage: TRANSFORMERS_PACKAGE,
259
- onPhase: phase =>
260
- sendRuntimeInstallProgress(
261
- transport,
262
- requestId,
263
- modelKey,
264
- phase,
265
- `${TRANSFORMERS_PACKAGE}@${getTransformersVersionSpec()}`,
266
- ),
267
- });
268
- const entry = await prepareCompiledRuntime(runtimeDir);
269
- const require_ = createRequire(entry);
270
- return configureTransformers(require_(entry) as TransformersRuntime);
271
- })().catch(error => {
272
- transformersRuntime = null;
273
- throw error;
274
- });
275
- return transformersRuntime;
276
- }
277
-
278
175
  /**
279
176
  * Resolve the native `sherpa-onnx-node` module. In a compiled binary the addon
280
177
  * (plus its per-platform prebuilt `sherpa-onnx.node` + bundled onnxruntime
@@ -283,69 +180,25 @@ async function loadTransformers(
283
180
  * is enough — no module-resolver patch or bare-require stubbing is needed.
284
181
  * Memoized so the runtime loads once per process.
285
182
  */
286
- async function loadSherpaRuntime(
287
- transport: SttTransport,
288
- requestId: string,
289
- modelKey: SttModelKey,
290
- ): Promise<SherpaRuntime> {
291
- if (sherpaRuntime) return sherpaRuntime;
292
- sherpaRuntime = (async () => {
183
+ function loadSherpaRuntime(transport: SttTransport, requestId: string, modelKey: SttModelKey): Promise<SherpaRuntime> {
184
+ return sherpaRuntime.load(async () => {
293
185
  if (!isCompiledBinary()) return sourceRequire(SHERPA_PACKAGE) as SherpaRuntime;
294
186
  const runtimeDir = await ensureRuntimeInstalled({
295
187
  runtimeDir: getSherpaRuntimeDir(),
296
188
  install: { dependencies: { [SHERPA_PACKAGE]: getSherpaVersionSpec() } },
297
189
  probePackage: SHERPA_PACKAGE,
298
190
  onPhase: phase =>
299
- sendRuntimeInstallProgress(
300
- transport,
301
- requestId,
302
- modelKey,
303
- phase,
304
- `${SHERPA_PACKAGE}@${getSherpaVersionSpec()}`,
305
- ),
191
+ transport.send({
192
+ type: "progress",
193
+ id: requestId,
194
+ event: { modelKey, status: phase, name: `${SHERPA_PACKAGE}@${getSherpaVersionSpec()}` },
195
+ }),
306
196
  });
307
197
  const nodeModules = path.join(runtimeDir, "node_modules");
308
198
  const entry = resolveRuntimeModule(nodeModules, SHERPA_PACKAGE);
309
199
  if (!entry) throw new Error(`Unable to resolve ${SHERPA_PACKAGE} in compiled runtime at ${nodeModules}`);
310
200
  return createRequire(entry)(entry) as SherpaRuntime;
311
- })().catch(error => {
312
- sherpaRuntime = null;
313
- throw error;
314
201
  });
315
- return sherpaRuntime;
316
- }
317
-
318
- function toProgressEvent(modelKey: SttModelKey, info: ProgressInfo): SttProgressEvent {
319
- if (info.status === "ready") {
320
- return { modelKey, status: info.status, task: info.task, model: info.model };
321
- }
322
- if (info.status === "progress_total") {
323
- return {
324
- modelKey,
325
- status: info.status,
326
- name: info.name,
327
- progress: info.progress,
328
- loaded: info.loaded,
329
- total: info.total,
330
- files: info.files,
331
- };
332
- }
333
- if (info.status === "progress") {
334
- return {
335
- modelKey,
336
- status: info.status,
337
- name: info.name,
338
- file: info.file,
339
- progress: info.progress,
340
- loaded: info.loaded,
341
- total: info.total,
342
- };
343
- }
344
- return { modelKey, status: info.status, name: info.name, file: info.file };
345
- }
346
-
347
- function sendProgress(transport: SttTransport, id: string, modelKey: SttModelKey, info: ProgressInfo): void {
348
- transport.send({ type: "progress", id, event: toProgressEvent(modelKey, info) });
349
202
  }
350
203
 
351
204
  async function loadPipelineOnDevice(
@@ -407,7 +260,13 @@ async function loadTransformersModel(
407
260
  transport: SttTransport,
408
261
  requestId: string,
409
262
  ): Promise<LoadedModel> {
410
- const transformers = await loadTransformers(transport, requestId, modelKey);
263
+ const transformers = await loadTransformersRuntime(
264
+ transformersRuntime,
265
+ transport,
266
+ requestId,
267
+ modelKey,
268
+ getSttRuntimeDir,
269
+ );
411
270
  const startedAt = performance.now();
412
271
  const { pipeline, device } = await loadPipelineWithDeviceFallback(
413
272
  transformers,
@@ -548,19 +407,8 @@ async function loadSherpaModel(
548
407
  async function loadModel(modelKey: SttModelKey, transport: SttTransport, requestId: string): Promise<LoadedModel> {
549
408
  const spec = getSttModelSpec(modelKey);
550
409
  if (!spec) throw new Error(`Unknown stt model: ${modelKey}`);
551
- const cached = models.get(modelKey);
552
- if (cached) {
553
- void cached
554
- .then(() => {
555
- transport.send({
556
- type: "progress",
557
- id: requestId,
558
- event: { modelKey, status: "ready", task: ASR_TASK, model: spec.repo },
559
- });
560
- })
561
- .catch(() => undefined);
562
- return cached;
563
- }
410
+ const cached = replayCachedReady(models, modelKey, transport, requestId, ASR_TASK, spec.repo);
411
+ if (cached) return cached;
564
412
 
565
413
  const loading =
566
414
  spec.engine === "sherpa"
@@ -0,0 +1,297 @@
1
+ import * as path from "node:path";
2
+ import { $env, isBunTestRuntime, isCompiledBinary, logger, workerHostEntry } from "@oh-my-pi/pi-utils";
3
+ import type { Subprocess } from "bun";
4
+
5
+ /**
6
+ * Shared lifecycle scaffolding for the ONNX inference subprocess clients
7
+ * (mnemopi embeddings, speech-to-text, tiny-model titles/completions, TTS).
8
+ * Each runs `onnxruntime-node` inside a dedicated Bun child process so the NAPI
9
+ * constructor/finalizer never executes in the main agent address space — those
10
+ * destructors segfault Bun on shutdown (issues #1606 / #1607 / #3031).
11
+ *
12
+ * Only the genuinely identical pieces live here: the worker-handle shape, the
13
+ * spawn-command resolution, the parent-env snapshot, the `Bun.spawn` wiring,
14
+ * the inline "worker unavailable" stub, and the ping/pong smoke probe. Each
15
+ * client keeps its own divergent request/response correlation, streaming, and
16
+ * teardown semantics.
17
+ */
18
+
19
+ /** Minimal inbound contract shared by every worker: a correlated `ping`. */
20
+ export type WorkerInboundBase = { type: "ping"; id: string };
21
+
22
+ /** Structured log line forwarded from a worker to the parent logger. */
23
+ export type WorkerLogMessage = {
24
+ type: "log";
25
+ level: "debug" | "warn" | "error";
26
+ msg: string;
27
+ meta?: Record<string, unknown>;
28
+ };
29
+
30
+ /** Minimal outbound contract shared by every worker: `pong`, `error`, `log`. */
31
+ export type WorkerOutboundBase =
32
+ | { type: "pong"; id: string }
33
+ | { type: "error"; id: string; error: string }
34
+ | WorkerLogMessage;
35
+
36
+ /**
37
+ * Parent-side view of a worker subprocess: send typed inbound messages,
38
+ * subscribe to outbound messages and worker errors, and hard-terminate.
39
+ */
40
+ export interface WorkerHandle<Inbound, Outbound> {
41
+ send(message: Inbound): void;
42
+ onMessage(handler: (message: Outbound) => void): () => void;
43
+ onError(handler: (error: Error) => void): () => void;
44
+ terminate(): Promise<void>;
45
+ }
46
+
47
+ /**
48
+ * A {@link WorkerHandle} that can also be (un)referenced so a pending request
49
+ * keeps the parent event loop alive while an idle worker never blocks exit.
50
+ */
51
+ export interface RefCountedWorkerHandle<Inbound, Outbound> extends WorkerHandle<Inbound, Outbound> {
52
+ /** Re-reference the subprocess so a pending request keeps the parent event loop alive. */
53
+ ref(): void;
54
+ /** Drop the reference once the worker is idle so it never blocks process exit. */
55
+ unref(): void;
56
+ }
57
+
58
+ /** The raw spawned subprocess plus the parent-side fan-out sets. */
59
+ export interface SpawnedSubprocess<Outbound> {
60
+ proc: Subprocess<"ignore", "ignore", "ignore">;
61
+ inbound: Set<(message: Outbound) => void>;
62
+ errors: Set<(error: Error) => void>;
63
+ /**
64
+ * Flipped to `true` right before the deliberate SIGKILL so `onExit` can
65
+ * distinguish the expected hard-kill from a crash (SIGSEGV from a native
66
+ * fault, OOM SIGKILL, operator `kill -9`). Only the latter surfaces as a
67
+ * worker error so callers don't await forever.
68
+ */
69
+ intentionalExit: { value: boolean };
70
+ }
71
+
72
+ export interface WorkerSpawnCommand {
73
+ cmd: string[];
74
+ cwd?: string;
75
+ }
76
+
77
+ /**
78
+ * Cold-starting a worker from a compiled binary (decompress + module graph
79
+ * load) is slow on contended CI runners; the probe only proves the worker
80
+ * spawns and ponges, so a generous bound removes flakes without weakening it.
81
+ */
82
+ export const SMOKE_TEST_TIMEOUT_MS = 30_000;
83
+
84
+ /**
85
+ * Resolve the command used to relaunch the agent CLI into worker mode. In a
86
+ * compiled binary the entry point is the binary itself; otherwise re-enter the
87
+ * declared worker-host entry with a cwd-relative script path (Bun's subprocess
88
+ * IPC is more reliable that way under `bun test`), falling back to this
89
+ * package's own `src/cli.ts` when no host entry is declared (bun test, SDK
90
+ * embedding).
91
+ */
92
+ export function resolveWorkerSpawnCmd(workerArg: string): WorkerSpawnCommand {
93
+ if (isCompiledBinary()) return { cmd: [process.execPath, workerArg] };
94
+ const hostEntry = workerHostEntry();
95
+ if (hostEntry) {
96
+ return { cmd: [process.execPath, path.basename(hostEntry), workerArg], cwd: path.dirname(hostEntry) };
97
+ }
98
+ const packageRoot = path.resolve(import.meta.dir, "..", "..");
99
+ return { cmd: [process.execPath, "src/cli.ts", workerArg], cwd: packageRoot };
100
+ }
101
+
102
+ /**
103
+ * Snapshot the parent environment for the child. `process.env` carries
104
+ * `undefined` slots that `Bun.spawn` rejects, so filter them out; an optional
105
+ * `overlay` (e.g. the tiny-model device/dtype vars) wins over inherited keys.
106
+ */
107
+ export function workerEnvFromParent(overlay?: Record<string, string>): Record<string, string> {
108
+ const base = $env as Record<string, string | undefined>;
109
+ const merged: Record<string, string> = {};
110
+ for (const key in base) {
111
+ const value = base[key];
112
+ if (typeof value === "string") merged[key] = value;
113
+ }
114
+ if (overlay) {
115
+ for (const key in overlay) merged[key] = overlay[key];
116
+ }
117
+ return merged;
118
+ }
119
+
120
+ /**
121
+ * Spawn an inference worker subprocess and wire its IPC fan-out. The child
122
+ * inherits no stdio (native model runtimes may otherwise print progress or
123
+ * decoded text and corrupt the chat scrollback) and is `unref`'d outside `bun
124
+ * test` so an idle worker never blocks process exit. `exitLabel` prefixes the
125
+ * worker-error message surfaced for an unexpected (non-intentional) exit.
126
+ */
127
+ export function createWorkerSubprocess<Outbound>(options: {
128
+ spawnCommand: WorkerSpawnCommand;
129
+ env: Record<string, string>;
130
+ exitLabel: string;
131
+ }): SpawnedSubprocess<Outbound> {
132
+ const inbound = new Set<(message: Outbound) => void>();
133
+ const errors = new Set<(error: Error) => void>();
134
+ const intentionalExit = { value: false };
135
+ const proc = Bun.spawn({
136
+ cmd: options.spawnCommand.cmd,
137
+ cwd: options.spawnCommand.cwd,
138
+ env: options.env,
139
+ stdin: "ignore",
140
+ stdout: "ignore",
141
+ stderr: "ignore",
142
+ serialization: "advanced",
143
+ windowsHide: true,
144
+ ipc(message) {
145
+ for (const handler of inbound) handler(message as Outbound);
146
+ },
147
+ onExit(_proc, exitCode, signalCode) {
148
+ if (exitCode === 0) return;
149
+ // Swallow only the expected SIGKILL from `terminate()`; every other
150
+ // signal exit (SIGSEGV from a native fault, OOM SIGKILL, operator
151
+ // `kill -9`) is a real worker death that must fault in-flight
152
+ // requests so callers don't await forever.
153
+ if (exitCode === null && intentionalExit.value) return;
154
+ const reason = exitCode !== null ? `code ${exitCode}` : `signal ${signalCode ?? "unknown"}`;
155
+ const err = new Error(`${options.exitLabel} exited with ${reason}`);
156
+ for (const handler of errors) handler(err);
157
+ },
158
+ });
159
+ // Don't keep the parent event loop alive on an idle worker; the dispose
160
+ // path calls `terminate()` explicitly. Bun's test runner starves IPC for
161
+ // unref'd subprocesses, so keep it referenced only under tests.
162
+ if (!isBunTestRuntime()) proc.unref();
163
+ return { proc, inbound, errors, intentionalExit };
164
+ }
165
+
166
+ /**
167
+ * Wrap a {@link SpawnedSubprocess} as a {@link WorkerHandle}. The `send`
168
+ * strategy is injected so each client keeps its exact IPC-send behaviour (e.g.
169
+ * `safeSend` vs an inline guarded `proc.send`). `terminate()` SIGKILLs: the
170
+ * point of subprocess isolation is that the parent never runs
171
+ * `onnxruntime-node`'s NAPI finalizer (it crashes Bun on Windows), so the OS
172
+ * reclaims the model memory instead. The intentional-exit flag is flipped
173
+ * *before* the kill so `onExit` can tell it apart from a native crash.
174
+ */
175
+ export function createWorkerHandle<Inbound, Outbound>(
176
+ spawned: SpawnedSubprocess<Outbound>,
177
+ send: (message: Inbound) => void,
178
+ ): WorkerHandle<Inbound, Outbound> {
179
+ const { proc, inbound, errors, intentionalExit } = spawned;
180
+ return {
181
+ send,
182
+ onMessage(handler) {
183
+ inbound.add(handler);
184
+ return () => inbound.delete(handler);
185
+ },
186
+ onError(handler) {
187
+ errors.add(handler);
188
+ return () => errors.delete(handler);
189
+ },
190
+ async terminate() {
191
+ intentionalExit.value = true;
192
+ try {
193
+ proc.kill("SIGKILL");
194
+ } catch {
195
+ // Already gone.
196
+ }
197
+ },
198
+ };
199
+ }
200
+
201
+ /**
202
+ * A stand-in handle used when the worker subprocess cannot be spawned. It
203
+ * ponges `ping` (so the smoke probe and readiness checks still resolve) and
204
+ * answers every other request with the spawn error so callers fail fast
205
+ * instead of awaiting forever.
206
+ */
207
+ export function createUnavailableWorker<
208
+ Inbound extends { type: string; id: string },
209
+ Outbound extends { type: string },
210
+ >(error: unknown): WorkerHandle<Inbound, Outbound> {
211
+ const listeners = new Set<(message: Outbound) => void>();
212
+ const errorMessage = error instanceof Error ? error.message : String(error);
213
+ const emit = (message: WorkerOutboundBase): void => {
214
+ // The stub only ever emits pong/error — members of every concrete worker
215
+ // Outbound union — but the generic cannot prove it, hence the assertion.
216
+ for (const listener of listeners) listener(message as unknown as Outbound);
217
+ };
218
+ return {
219
+ send(message) {
220
+ queueMicrotask(() => {
221
+ if (message.type === "ping") {
222
+ emit({ type: "pong", id: message.id });
223
+ return;
224
+ }
225
+ emit({ type: "error", id: message.id, error: errorMessage });
226
+ });
227
+ },
228
+ onMessage(handler) {
229
+ listeners.add(handler);
230
+ return () => listeners.delete(handler);
231
+ },
232
+ onError() {
233
+ return () => {};
234
+ },
235
+ async terminate() {
236
+ listeners.clear();
237
+ },
238
+ };
239
+ }
240
+
241
+ /**
242
+ * Spawn a worker handle, falling back to {@link createUnavailableWorker} (after
243
+ * a warning) when the subprocess cannot be created so the feature degrades
244
+ * gracefully instead of throwing into callers.
245
+ */
246
+ export function spawnWorkerOrUnavailable<Handle>(
247
+ spawn: () => Handle,
248
+ unavailable: (error: unknown) => Handle,
249
+ warnMessage: string,
250
+ ): Handle {
251
+ try {
252
+ return spawn();
253
+ } catch (error) {
254
+ logger.warn(warnMessage, { error: error instanceof Error ? error.message : String(error) });
255
+ return unavailable(error);
256
+ }
257
+ }
258
+
259
+ /** Forward a worker's structured `log` message to the matching logger level. */
260
+ export function logWorkerMessage(message: WorkerLogMessage): void {
261
+ if (message.level === "debug") logger.debug(message.msg, message.meta);
262
+ else if (message.level === "warn") logger.warn(message.msg, message.meta);
263
+ else logger.error(message.msg, message.meta);
264
+ }
265
+
266
+ /**
267
+ * Drive the ping/pong readiness probe wired into `omp --smoke-test`: send one
268
+ * `ping`, resolve on the first `pong` (ignoring `log` chatter), and reject on
269
+ * any other message, a worker error, or the timeout. Always tears the handle
270
+ * down on the way out. `label` prefixes the failure messages.
271
+ */
272
+ export async function smokeTestWorker<Inbound extends { type: string; id: string }, Outbound extends { type: string }>(
273
+ handle: WorkerHandle<Inbound, Outbound>,
274
+ label: string,
275
+ timeoutMs: number,
276
+ ): Promise<void> {
277
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
278
+ const timer = setTimeout(() => reject(new Error(`${label} did not pong within ${timeoutMs}ms`)), timeoutMs);
279
+ const unsubscribeMessage = handle.onMessage(message => {
280
+ if (message.type === "pong") {
281
+ resolve();
282
+ return;
283
+ }
284
+ if (message.type === "log") return;
285
+ reject(new Error(`${label}: expected pong, got ${JSON.stringify(message)}`));
286
+ });
287
+ const unsubscribeError = handle.onError(reject);
288
+ try {
289
+ handle.send({ type: "ping", id: "smoke" } as Inbound);
290
+ await promise;
291
+ } finally {
292
+ clearTimeout(timer);
293
+ unsubscribeMessage();
294
+ unsubscribeError();
295
+ await handle.terminate();
296
+ }
297
+ }