@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
@@ -164,12 +164,14 @@ import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
164
164
  import { loadCapability } from "../discovery";
165
165
  import { expandApplyPatchToEntries, normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
166
166
  import { getFileSnapshotStore } from "../edit/file-snapshot-store";
167
+ import { disposeJuliaKernelSessionsByOwner } from "../eval/jl/executor";
167
168
  import { namespaceSessionId as namespacePythonSessionId } from "../eval/py";
168
169
  import {
169
170
  disposeKernelSessionsByOwner,
170
171
  executePython as executePythonCommand,
171
172
  type PythonResult,
172
173
  } from "../eval/py/executor";
174
+ import { disposeRubyKernelSessionsByOwner } from "../eval/rb/executor";
173
175
  import { defaultEvalSessionId } from "../eval/session-id";
174
176
  import { type BashResult, executeBash as executeBashCommand } from "../exec/bash-executor";
175
177
  import type { TtsrManager, TtsrMatchContext } from "../export/ttsr";
@@ -520,7 +522,7 @@ export interface AgentSessionConfig {
520
522
  obfuscator?: SecretObfuscator;
521
523
  /** Inherited eval executor session id from a parent agent. */
522
524
  parentEvalSessionId?: string;
523
- /** Logical owner for retained Python kernels created by this session. */
525
+ /** Logical owner for retained eval kernels created by this session. */
524
526
  evalKernelOwnerId?: string;
525
527
  /**
526
528
  * AsyncJobManager that this session installed as the process-global instance.
@@ -1934,6 +1936,7 @@ export class AgentSession {
1934
1936
  snapshotMessages: () => this.agent.state.messages,
1935
1937
  enqueueAdvice,
1936
1938
  maintainContext: incomingTokens => this.#maintainAdvisorContext(incomingTokens),
1939
+ obfuscator: this.#obfuscator,
1937
1940
  });
1938
1941
  if (seedToCurrent) {
1939
1942
  this.#advisorRuntime.seedTo(this.agent.state.messages.length);
@@ -4173,7 +4176,7 @@ export class AgentSession {
4173
4176
 
4174
4177
  /**
4175
4178
  * Synchronously mark the session as disposing so new work is rejected
4176
- * immediately: Python/eval starts throw, queued asides are dropped, and the
4179
+ * immediately: eval starts throw, queued asides are dropped, and the
4177
4180
  * aside provider is detached. Idempotent; `dispose()` runs it first.
4178
4181
  *
4179
4182
  * Wrappers that await other teardown before delegating to `dispose()` MUST
@@ -4235,13 +4238,13 @@ export class AgentSession {
4235
4238
  AsyncJobManager.setInstance(undefined);
4236
4239
  }
4237
4240
  }
4238
- const pythonExecutionsSettled = await this.#prepareEvalExecutionsForDispose();
4239
- if (!pythonExecutionsSettled) {
4240
- logger.warn(
4241
- "Detaching retained Python kernel ownership during dispose while Python execution is still active",
4242
- );
4241
+ const evalExecutionsSettled = await this.#prepareEvalExecutionsForDispose();
4242
+ if (!evalExecutionsSettled) {
4243
+ logger.warn("Detaching retained eval-kernel ownership during dispose while eval execution is still active");
4243
4244
  }
4244
4245
  await disposeKernelSessionsByOwner(this.#evalKernelOwnerId);
4246
+ await disposeRubyKernelSessionsByOwner(this.#evalKernelOwnerId);
4247
+ await disposeJuliaKernelSessionsByOwner(this.#evalKernelOwnerId);
4245
4248
  await shutdownTinyTitleClient();
4246
4249
  this.#releasePowerAssertion();
4247
4250
  await this.sessionManager.close();
@@ -9024,6 +9027,21 @@ export class AgentSession {
9024
9027
  providerKeys.add(`openai-responses:${nextModel.provider}`);
9025
9028
  }
9026
9029
 
9030
+ // `openai-completions` sessions are keyed `openai-completions:<provider>:<resolvedBaseUrl>:<modelId>`
9031
+ // and cache backend-specific decisions (strict-tools disable scopes, reasoning-effort
9032
+ // fallbacks). The resolved request base URL can differ from the catalog `model.baseUrl`
9033
+ // (Moonshot env override, Alibaba Coding Plan enterprise URL, Azure deployment URL),
9034
+ // so evict by provider prefix when the user moves away from that completions backend.
9035
+ let completionsPrefixToEvict: string | undefined;
9036
+ if (currentModel.api === "openai-completions") {
9037
+ const currentScope = `${currentModel.provider}:${currentModel.baseUrl ?? ""}`;
9038
+ const nextScope =
9039
+ nextModel.api === "openai-completions" ? `${nextModel.provider}:${nextModel.baseUrl ?? ""}` : undefined;
9040
+ if (currentScope !== nextScope) {
9041
+ completionsPrefixToEvict = `openai-completions:${currentModel.provider}:`;
9042
+ }
9043
+ }
9044
+
9027
9045
  for (const providerKey of providerKeys) {
9028
9046
  const state = this.#providerSessionState.get(providerKey);
9029
9047
  if (!state) continue;
@@ -9039,6 +9057,21 @@ export class AgentSession {
9039
9057
 
9040
9058
  this.#providerSessionState.delete(providerKey);
9041
9059
  }
9060
+
9061
+ if (completionsPrefixToEvict !== undefined) {
9062
+ for (const [key, state] of this.#providerSessionState) {
9063
+ if (!key.startsWith(completionsPrefixToEvict)) continue;
9064
+ try {
9065
+ state.close();
9066
+ } catch (error) {
9067
+ logger.warn("Failed to close provider session state during model switch", {
9068
+ providerKey: key,
9069
+ error: String(error),
9070
+ });
9071
+ }
9072
+ this.#providerSessionState.delete(key);
9073
+ }
9074
+ }
9042
9075
  }
9043
9076
 
9044
9077
  #normalizeProviderReplayValue(value: unknown): unknown {
@@ -12166,8 +12199,8 @@ export class AgentSession {
12166
12199
  pendingMessages?: AgentMessage[];
12167
12200
  }): ContextUsageBreakdown | undefined {
12168
12201
  const model = this.model;
12169
- const contextWindow = options?.contextWindow ?? model?.contextWindow ?? 0;
12170
- if (!Number.isFinite(contextWindow) || contextWindow <= 0) return undefined;
12202
+ const rawContextWindow = options?.contextWindow ?? model?.contextWindow ?? 0;
12203
+ const contextWindow = Number.isFinite(rawContextWindow) && rawContextWindow > 0 ? rawContextWindow : 0;
12171
12204
 
12172
12205
  const { skillsTokens, toolsTokens, systemContextTokens, systemPromptTokens } = computeNonMessageBreakdown(this);
12173
12206
  const categoryNonMessageTokens = skillsTokens + toolsTokens + systemContextTokens + systemPromptTokens;
@@ -602,11 +602,12 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
602
602
  },
603
603
  {
604
604
  name: "share",
605
- description: "Share session via an encrypted link (secret gist or share server)",
605
+ description: "Share session via an encrypted link (share server or secret gist)",
606
606
  handle: async (_command, runtime) => {
607
607
  try {
608
608
  const result = await shareSession(runtime.sessionManager, {
609
609
  serverUrl: runtime.settings.get("share.serverUrl"),
610
+ store: runtime.settings.get("share.store"),
610
611
  state: runtime.session.state,
611
612
  obfuscator: runtime.settings.get("share.redactSecrets") ? runtime.session.obfuscator : undefined,
612
613
  });
@@ -1,26 +1,21 @@
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
- import { settings } from "../config/settings";
5
- import { tinyWorkerEnvOverlay } from "../tiny/title-client";
1
+ import { logger } from "@oh-my-pi/pi-utils";
2
+ import {
3
+ createUnavailableWorker,
4
+ createWorkerHandle,
5
+ createWorkerSubprocess,
6
+ logWorkerMessage,
7
+ resolveWorkerSpawnCmd,
8
+ SMOKE_TEST_TIMEOUT_MS,
9
+ type SpawnedSubprocess,
10
+ smokeTestWorker,
11
+ spawnWorkerOrUnavailable,
12
+ type WorkerHandle,
13
+ } from "../subprocess/worker-client";
14
+ import { tinyWorkerEnv } from "../tiny/title-client";
6
15
  import { safeSend } from "../utils/ipc";
7
16
  import type { SttProgressEvent, SttWorkerInbound, SttWorkerOutbound } from "./asr-protocol";
8
17
  import type { SttModelKey } from "./models";
9
18
 
10
- /**
11
- * Abstraction over the speech-recognition subprocess. Modelled as a worker
12
- * interface so the parent composes lifecycle, ping/pong, and request/response
13
- * correlation uniformly; the runtime implementation is a Bun child process so
14
- * `onnxruntime-node`'s NAPI finalizer never runs inside the main agent address
15
- * space — that destructor segfaults Bun on shutdown (issue #1606).
16
- */
17
- interface WorkerHandle {
18
- send(message: SttWorkerInbound): void;
19
- onMessage(handler: (message: SttWorkerOutbound) => void): () => void;
20
- onError(handler: (error: Error) => void): () => void;
21
- terminate(): Promise<void>;
22
- }
23
-
24
19
  type PendingRequest =
25
20
  | { kind: "transcribe"; modelKey: SttModelKey; resolve: (text: string) => void; reject: (error: Error) => void }
26
21
  | { kind: "download"; modelKey: SttModelKey; resolve: (ok: boolean) => void };
@@ -64,205 +59,50 @@ interface StreamState {
64
59
  finish: (apply: () => void) => void;
65
60
  }
66
61
 
67
- // Cold-starting the worker subprocess from a compiled binary (decompress +
68
- // module graph load) is slow on contended CI runners; the probe only needs to
69
- // prove the worker spawns and ponges, so a generous bound removes the flake.
70
- const SMOKE_TEST_TIMEOUT_MS = 30_000;
71
-
72
62
  /**
73
63
  * Hidden subcommand on the main CLI that boots the speech-recognition worker in
74
64
  * the spawned subprocess. Kept in sync with the dispatch in `cli.ts`.
75
65
  */
76
66
  export const STT_WORKER_ARG = "__omp_worker_stt";
77
67
 
78
- function readTinyModelSetting(key: "providers.tinyModelDevice" | "providers.tinyModelDtype"): string | undefined {
79
- try {
80
- const value = settings.get(key);
81
- return typeof value === "string" ? value : undefined;
82
- } catch {
83
- // Settings may be uninitialized (e.g. `omp --smoke-test`); fall back to env/default.
84
- return undefined;
85
- }
86
- }
87
-
88
- /**
89
- * Env handed to the speech subprocess. The `PI_TINY_DEVICE` / `PI_TINY_DTYPE`
90
- * env vars win; otherwise the persisted `providers.tinyModelDevice` /
91
- * `providers.tinyModelDtype` settings are mapped onto those vars so the
92
- * subprocess's env-based resolution picks them up (shared with tiny models).
93
- */
94
- function sttWorkerEnv(): Record<string, string> {
95
- const overlay = tinyWorkerEnvOverlay(
96
- $env,
97
- readTinyModelSetting("providers.tinyModelDevice"),
98
- readTinyModelSetting("providers.tinyModelDtype"),
99
- );
100
- const base = $env as Record<string, string | undefined>;
101
- const merged: Record<string, string> = {};
102
- for (const key in base) {
103
- const value = base[key];
104
- if (typeof value === "string") merged[key] = value;
105
- }
106
- for (const key in overlay) merged[key] = overlay[key];
107
- return merged;
108
- }
109
-
110
- interface SttWorkerSpawnCommand {
111
- cmd: string[];
112
- cwd?: string;
113
- }
114
-
115
- /**
116
- * Resolve the command used to relaunch the agent CLI into stt-worker mode. In a
117
- * compiled binary the entry point is the binary itself; otherwise re-enter the
118
- * declared worker-host entry with a cwd-relative script path (Bun's subprocess
119
- * IPC is more reliable that way under `bun test`), falling back to this
120
- * package's own `src/cli.ts` when no host entry is declared.
121
- */
122
- function sttWorkerSpawnCmd(): SttWorkerSpawnCommand {
123
- if (isCompiledBinary()) return { cmd: [process.execPath, STT_WORKER_ARG] };
124
- const hostEntry = workerHostEntry();
125
- if (hostEntry) {
126
- return { cmd: [process.execPath, path.basename(hostEntry), STT_WORKER_ARG], cwd: path.dirname(hostEntry) };
127
- }
128
- const packageRoot = path.resolve(import.meta.dir, "..", "..");
129
- return { cmd: [process.execPath, "src/cli.ts", STT_WORKER_ARG], cwd: packageRoot };
130
- }
131
-
132
- interface SpawnedSubprocess {
133
- proc: Subprocess<"ignore", "ignore", "ignore">;
134
- inbound: Set<(message: SttWorkerOutbound) => void>;
135
- errors: Set<(error: Error) => void>;
136
- /**
137
- * Flipped to `true` right before the parent SIGKILLs the child so `onExit`
138
- * can distinguish the expected hard-kill from a crash/OOM/external signal.
139
- */
140
- intentionalExit: { value: boolean };
141
- }
142
-
143
68
  /**
144
69
  * Spawn the speech worker as a subprocess. Exported for tests and the smoke
145
70
  * probe; production callers go through {@link spawnSttWorker}.
146
71
  */
147
- export function createSttSubprocess(): SpawnedSubprocess {
148
- const inbound = new Set<(message: SttWorkerOutbound) => void>();
149
- const errors = new Set<(error: Error) => void>();
150
- const intentionalExit = { value: false };
151
- const spawnCommand = sttWorkerSpawnCmd();
152
- const proc = Bun.spawn({
153
- cmd: spawnCommand.cmd,
154
- cwd: spawnCommand.cwd,
155
- env: sttWorkerEnv(),
156
- stdin: "ignore",
157
- stdout: "ignore",
158
- stderr: "ignore",
159
- serialization: "advanced",
160
- windowsHide: true,
161
- ipc(message) {
162
- for (const handler of inbound) handler(message as SttWorkerOutbound);
163
- },
164
- onExit(_proc, exitCode, signalCode) {
165
- if (exitCode === 0) return;
166
- // Swallow only the expected SIGKILL from `terminate()`; every other
167
- // signal exit is a real worker death that must fault in-flight
168
- // requests so callers don't await forever.
169
- if (exitCode === null && intentionalExit.value) return;
170
- const reason = exitCode !== null ? `code ${exitCode}` : `signal ${signalCode ?? "unknown"}`;
171
- const err = new Error(`stt subprocess exited with ${reason}`);
172
- for (const handler of errors) handler(err);
173
- },
72
+ export function createSttSubprocess(): SpawnedSubprocess<SttWorkerOutbound> {
73
+ return createWorkerSubprocess<SttWorkerOutbound>({
74
+ spawnCommand: resolveWorkerSpawnCmd(STT_WORKER_ARG),
75
+ env: tinyWorkerEnv(),
76
+ exitLabel: "stt subprocess",
174
77
  });
175
- // Don't keep the parent event loop alive on an idle worker; dispose calls
176
- // `terminate()` explicitly. Bun's test runner can starve IPC delivery for
177
- // unref'd subprocesses, so keep it referenced under tests.
178
- if (!isBunTestRuntime()) proc.unref();
179
- return { proc, inbound, errors, intentionalExit };
180
- }
181
-
182
- function wrapSubprocess({ proc, inbound, errors, intentionalExit }: SpawnedSubprocess): WorkerHandle {
183
- return {
184
- send(message) {
185
- safeSend(proc, message, "stt");
186
- },
187
- onMessage(handler) {
188
- inbound.add(handler);
189
- return () => inbound.delete(handler);
190
- },
191
- onError(handler) {
192
- errors.add(handler);
193
- return () => errors.delete(handler);
194
- },
195
- async terminate() {
196
- // SIGKILL: the whole point of subprocess isolation is that the parent
197
- // never runs `onnxruntime-node`'s NAPI finalizer. Hard-kill instead —
198
- // the model lives in process memory and the OS reclaims everything.
199
- intentionalExit.value = true;
200
- try {
201
- proc.kill("SIGKILL");
202
- } catch {
203
- // Already gone.
204
- }
205
- },
206
- };
207
- }
208
-
209
- function spawnInlineUnavailableWorker(error: unknown): WorkerHandle {
210
- const listeners = new Set<(message: SttWorkerOutbound) => void>();
211
- const errorMessage = error instanceof Error ? error.message : String(error);
212
- const emit = (message: SttWorkerOutbound): void => {
213
- for (const listener of listeners) listener(message);
214
- };
215
- return {
216
- send(message) {
217
- queueMicrotask(() => {
218
- if (message.type === "ping") {
219
- emit({ type: "pong", id: message.id });
220
- return;
221
- }
222
- emit({ type: "error", id: message.id, error: errorMessage });
223
- });
224
- },
225
- onMessage(handler) {
226
- listeners.add(handler);
227
- return () => listeners.delete(handler);
228
- },
229
- onError() {
230
- return () => {};
231
- },
232
- async terminate() {
233
- listeners.clear();
234
- },
235
- };
236
78
  }
237
79
 
238
- function spawnSttWorker(): WorkerHandle {
239
- try {
240
- return wrapSubprocess(createSttSubprocess());
241
- } catch (error) {
242
- logger.warn("stt worker spawn failed; speech-to-text disabled", {
243
- error: error instanceof Error ? error.message : String(error),
244
- });
245
- return spawnInlineUnavailableWorker(error);
246
- }
80
+ function wrapSubprocess(
81
+ spawned: SpawnedSubprocess<SttWorkerOutbound>,
82
+ ): WorkerHandle<SttWorkerInbound, SttWorkerOutbound> {
83
+ const { proc } = spawned;
84
+ return createWorkerHandle<SttWorkerInbound, SttWorkerOutbound>(spawned, message => safeSend(proc, message, "stt"));
247
85
  }
248
86
 
249
- function logWorkerMessage(message: Extract<SttWorkerOutbound, { type: "log" }>): void {
250
- if (message.level === "debug") logger.debug(message.msg, message.meta);
251
- else if (message.level === "warn") logger.warn(message.msg, message.meta);
252
- else logger.error(message.msg, message.meta);
87
+ function spawnSttWorker(): WorkerHandle<SttWorkerInbound, SttWorkerOutbound> {
88
+ return spawnWorkerOrUnavailable(
89
+ () => wrapSubprocess(createSttSubprocess()),
90
+ createUnavailableWorker<SttWorkerInbound, SttWorkerOutbound>,
91
+ "stt worker spawn failed; speech-to-text disabled",
92
+ );
253
93
  }
254
94
 
255
95
  export class SttClient {
256
- #worker: WorkerHandle | null = null;
96
+ #worker: WorkerHandle<SttWorkerInbound, SttWorkerOutbound> | null = null;
257
97
  #unsubscribeMessage: (() => void) | null = null;
258
98
  #unsubscribeError: (() => void) | null = null;
259
99
  #pending = new Map<string, PendingRequest>();
260
100
  #streams = new Map<string, StreamState>();
261
101
  #progressListeners = new Set<(event: SttProgressEvent) => void>();
262
102
  #nextRequestId = 0;
263
- #spawnWorker: () => WorkerHandle;
103
+ #spawnWorker: () => WorkerHandle<SttWorkerInbound, SttWorkerOutbound>;
264
104
 
265
- constructor(spawnWorker: () => WorkerHandle = spawnSttWorker) {
105
+ constructor(spawnWorker: () => WorkerHandle<SttWorkerInbound, SttWorkerOutbound> = spawnSttWorker) {
266
106
  this.#spawnWorker = spawnWorker;
267
107
  }
268
108
 
@@ -407,7 +247,7 @@ export class SttClient {
407
247
  }
408
248
  }
409
249
 
410
- #ensureWorker(): WorkerHandle {
250
+ #ensureWorker(): WorkerHandle<SttWorkerInbound, SttWorkerOutbound> {
411
251
  if (this.#worker) return this.#worker;
412
252
  const worker = this.#spawnWorker();
413
253
  this.#worker = worker;
@@ -497,25 +337,5 @@ export async function smokeTestSttWorker({
497
337
  }: {
498
338
  timeoutMs?: number;
499
339
  } = {}): Promise<void> {
500
- const handle = wrapSubprocess(createSttSubprocess());
501
- const { promise, resolve, reject } = Promise.withResolvers<void>();
502
- const timer = setTimeout(() => reject(new Error(`stt worker did not pong within ${timeoutMs}ms`)), timeoutMs);
503
- const unsubscribeMessage = handle.onMessage(message => {
504
- if (message.type === "pong") {
505
- resolve();
506
- return;
507
- }
508
- if (message.type === "log") return;
509
- reject(new Error(`stt worker: expected pong, got ${JSON.stringify(message)}`));
510
- });
511
- const unsubscribeError = handle.onError(reject);
512
- try {
513
- handle.send({ type: "ping", id: "smoke" } satisfies SttWorkerInbound);
514
- await promise;
515
- } finally {
516
- clearTimeout(timer);
517
- unsubscribeMessage();
518
- unsubscribeError();
519
- await handle.terminate();
520
- }
340
+ await smokeTestWorker(wrapSubprocess(createSttSubprocess()), "stt worker", timeoutMs);
521
341
  }