@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,142 @@
1
+ /**
2
+ * Shared Content-Length message framing for the JSON byte streams spoken by the
3
+ * LSP and DAP stdio clients. Both protocols use the same base-protocol framing:
4
+ * each message is a `Content-Length: <n>\r\n\r\n` header block followed by `<n>`
5
+ * bytes of UTF-8 JSON. This module owns the incremental decode so the two
6
+ * clients don't each reimplement chunk accumulation, header scanning, and the
7
+ * mid-message remainder handoff.
8
+ */
9
+
10
+ // Reused for all full (non-streaming) decodes; each decode() resets state, so a
11
+ // single instance is safe and avoids per-message TextDecoder allocation.
12
+ const MESSAGE_DECODER = new TextDecoder("utf-8");
13
+
14
+ /**
15
+ * Locate the `\r\n\r\n` header terminator across the pending chunk list.
16
+ * Returns the absolute byte index of the first `\r`, or -1 when not present.
17
+ * Equivalent to scanning the contiguous concatenation of the chunks.
18
+ */
19
+ function findHeaderEndInChunks(chunks: Buffer[]): number {
20
+ let global = 0;
21
+ let b0 = -1;
22
+ let b1 = -1;
23
+ let b2 = -1;
24
+ for (const chunk of chunks) {
25
+ for (let i = 0; i < chunk.length; i++) {
26
+ const b3 = chunk[i];
27
+ if (b0 === 13 && b1 === 10 && b2 === 13 && b3 === 10) {
28
+ return global - 3;
29
+ }
30
+ b0 = b1;
31
+ b1 = b2;
32
+ b2 = b3;
33
+ global++;
34
+ }
35
+ }
36
+ return -1;
37
+ }
38
+
39
+ /** Copy the byte range [from, to) out of the pending chunk list into one Buffer. */
40
+ function copyChunkRange(chunks: Buffer[], from: number, to: number): Buffer {
41
+ const out = Buffer.allocUnsafe(to - from);
42
+ let global = 0;
43
+ let written = 0;
44
+ for (const chunk of chunks) {
45
+ const chunkEnd = global + chunk.length;
46
+ if (chunkEnd > from && global < to) {
47
+ const start = Math.max(from, global) - global;
48
+ const end = Math.min(to, chunkEnd) - global;
49
+ chunk.copy(out, written, start, end);
50
+ written += end - start;
51
+ }
52
+ global = chunkEnd;
53
+ if (global >= to) break;
54
+ }
55
+ return out;
56
+ }
57
+
58
+ /** Drop the first `count` bytes from the pending chunk list in place. */
59
+ function dropChunkFront(chunks: Buffer[], count: number): void {
60
+ let removed = 0;
61
+ while (chunks.length > 0) {
62
+ const head = chunks[0];
63
+ if (removed + head.length <= count) {
64
+ removed += head.length;
65
+ chunks.shift();
66
+ } else {
67
+ chunks[0] = head.subarray(count - removed);
68
+ break;
69
+ }
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Incremental Content-Length frame decoder for a JSON message byte stream.
75
+ *
76
+ * Incoming bytes are buffered as a list of chunks and only joined when a full
77
+ * message is framed — concatenating the accumulator on every read is O(n^2) for
78
+ * messages that span many reads (e.g. a large initial diagnostics burst). Feed
79
+ * raw chunks with {@link push}, pull every complete message with {@link drain},
80
+ * and persist {@link remainder} when the reader stops so a restarted reader
81
+ * resumes mid-message.
82
+ */
83
+ export class MessageFramer {
84
+ readonly #pendingChunks: Buffer[] = [];
85
+ #pendingLen = 0;
86
+
87
+ /** Seed the buffer with any unparsed remainder left by a previous reader. */
88
+ constructor(seed: Buffer) {
89
+ if (seed.length > 0) {
90
+ this.#pendingChunks.push(seed);
91
+ this.#pendingLen = seed.length;
92
+ }
93
+ }
94
+
95
+ /** Append a freshly read chunk to the pending buffer. */
96
+ push(chunk: Buffer): void {
97
+ this.#pendingChunks.push(chunk);
98
+ this.#pendingLen += chunk.length;
99
+ }
100
+
101
+ /**
102
+ * Yield the JSON text of every complete message currently buffered. A header
103
+ * block without a `Content-Length` is non-protocol noise (e.g. a server
104
+ * printing to stdout); `onResync` is invoked with the offending header text
105
+ * and the framer drops past the bogus terminator to recover instead of
106
+ * stalling on the same junk header forever.
107
+ */
108
+ *drain(onResync: (headerText: string) => void): Generator<string> {
109
+ while (true) {
110
+ const headerEnd = findHeaderEndInChunks(this.#pendingChunks);
111
+ if (headerEnd === -1) break;
112
+
113
+ const headerText = MESSAGE_DECODER.decode(copyChunkRange(this.#pendingChunks, 0, headerEnd));
114
+ const contentLengthMatch = headerText.match(/Content-Length: (\d+)/i);
115
+ if (!contentLengthMatch) {
116
+ onResync(headerText);
117
+ dropChunkFront(this.#pendingChunks, headerEnd + 4);
118
+ this.#pendingLen -= headerEnd + 4;
119
+ continue;
120
+ }
121
+
122
+ const contentLength = Number.parseInt(contentLengthMatch[1], 10);
123
+ const messageStart = headerEnd + 4; // Skip \r\n\r\n
124
+ const messageEnd = messageStart + contentLength;
125
+ if (this.#pendingLen < messageEnd) break;
126
+
127
+ const messageText = MESSAGE_DECODER.decode(copyChunkRange(this.#pendingChunks, messageStart, messageEnd));
128
+ dropChunkFront(this.#pendingChunks, messageEnd);
129
+ this.#pendingLen -= messageEnd;
130
+ yield messageText;
131
+ }
132
+ }
133
+
134
+ /** The unparsed remainder, to persist when the reader stops. */
135
+ remainder(): Buffer {
136
+ return this.#pendingChunks.length === 0
137
+ ? Buffer.alloc(0)
138
+ : this.#pendingChunks.length === 1
139
+ ? this.#pendingChunks[0]
140
+ : Buffer.concat(this.#pendingChunks, this.#pendingLen);
141
+ }
142
+ }
package/src/lsp/client.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as path from "node:path";
2
2
  import { isEnoent, logger, ptree, untilAborted } from "@oh-my-pi/pi-utils";
3
+ import { MessageFramer } from "../jsonrpc/message-framing";
3
4
  import { ToolAbortError, throwIfAborted } from "../tools/tool-errors";
4
5
  import { applyWorkspaceEdit } from "./edits";
5
6
  import { getLspmuxCommand, isLspmuxSupported } from "./lspmux";
@@ -179,69 +180,6 @@ const CLIENT_CAPABILITIES = {
179
180
  // LSP Message Protocol
180
181
  // =============================================================================
181
182
 
182
- // Reused for all full (non-streaming) decodes; each decode() resets state, so a
183
- // single instance is safe and avoids per-message TextDecoder allocation.
184
- const MESSAGE_DECODER = new TextDecoder("utf-8");
185
-
186
- /**
187
- * Locate the `\r\n\r\n` header terminator across the pending chunk list.
188
- * Returns the absolute byte index of the first `\r`, or -1 when not present.
189
- * Equivalent to scanning the contiguous concatenation of the chunks.
190
- */
191
- function findHeaderEndInChunks(chunks: Buffer[]): number {
192
- let global = 0;
193
- let b0 = -1;
194
- let b1 = -1;
195
- let b2 = -1;
196
- for (const chunk of chunks) {
197
- for (let i = 0; i < chunk.length; i++) {
198
- const b3 = chunk[i];
199
- if (b0 === 13 && b1 === 10 && b2 === 13 && b3 === 10) {
200
- return global - 3;
201
- }
202
- b0 = b1;
203
- b1 = b2;
204
- b2 = b3;
205
- global++;
206
- }
207
- }
208
- return -1;
209
- }
210
-
211
- /** Copy the byte range [from, to) out of the pending chunk list into one Buffer. */
212
- function copyChunkRange(chunks: Buffer[], from: number, to: number): Buffer {
213
- const out = Buffer.allocUnsafe(to - from);
214
- let global = 0;
215
- let written = 0;
216
- for (const chunk of chunks) {
217
- const chunkEnd = global + chunk.length;
218
- if (chunkEnd > from && global < to) {
219
- const start = Math.max(from, global) - global;
220
- const end = Math.min(to, chunkEnd) - global;
221
- chunk.copy(out, written, start, end);
222
- written += end - start;
223
- }
224
- global = chunkEnd;
225
- if (global >= to) break;
226
- }
227
- return out;
228
- }
229
-
230
- /** Drop the first `count` bytes from the pending chunk list in place. */
231
- function dropChunkFront(chunks: Buffer[], count: number): void {
232
- let removed = 0;
233
- while (chunks.length > 0) {
234
- const head = chunks[0];
235
- if (removed + head.length <= count) {
236
- removed += head.length;
237
- chunks.shift();
238
- } else {
239
- chunks[0] = head.subarray(count - removed);
240
- break;
241
- }
242
- }
243
- }
244
-
245
183
  async function writeMessage(
246
184
  sink: Bun.FileSink,
247
185
  message: LspJsonRpcRequest | LspJsonRpcNotification | LspJsonRpcResponse,
@@ -274,54 +212,25 @@ async function startMessageReader(client: LspClient): Promise<void> {
274
212
 
275
213
  const reader = (client.proc.stdout as ReadableStream<Uint8Array>).getReader();
276
214
 
277
- // Incoming bytes are buffered as a list of chunks and only joined when a full
278
- // message is framed. Concatenating the accumulator on every read was O(n^2)
279
- // for messages that span many reads (e.g. a large initial diagnostics burst).
280
- const pendingChunks: Buffer[] = [];
281
- let pendingLen = 0;
282
- if (client.messageBuffer.length > 0) {
283
- const seed = Buffer.from(client.messageBuffer);
284
- pendingChunks.push(seed);
285
- pendingLen = seed.length;
286
- }
215
+ const framer = new MessageFramer(Buffer.from(client.messageBuffer));
287
216
 
288
217
  try {
289
218
  while (true) {
290
219
  const { done, value } = await reader.read();
291
220
  if (done) break;
292
221
 
293
- pendingChunks.push(Buffer.from(value));
294
- pendingLen += value.length;
222
+ framer.push(Buffer.from(value));
295
223
 
296
224
  // Drain every complete message currently buffered.
297
- while (true) {
298
- const headerEnd = findHeaderEndInChunks(pendingChunks);
299
- if (headerEnd === -1) break;
300
-
301
- const headerText = MESSAGE_DECODER.decode(copyChunkRange(pendingChunks, 0, headerEnd));
302
- const contentLengthMatch = headerText.match(/Content-Length: (\d+)/i);
303
- if (!contentLengthMatch) {
304
- // Non-protocol bytes on stdout (e.g. a wrapper script printing).
305
- // Drop past the bogus terminator and resync instead of stalling
306
- // on the same junk header forever.
307
- logger.warn("LSP framing resync: header block without Content-Length", {
308
- server: client.name,
309
- header: headerText.slice(0, 200),
310
- });
311
- dropChunkFront(pendingChunks, headerEnd + 4);
312
- pendingLen -= headerEnd + 4;
313
- continue;
314
- }
315
-
316
- const contentLength = Number.parseInt(contentLengthMatch[1], 10);
317
- const messageStart = headerEnd + 4; // Skip \r\n\r\n
318
- const messageEnd = messageStart + contentLength;
319
- if (pendingLen < messageEnd) break;
320
-
321
- const messageText = MESSAGE_DECODER.decode(copyChunkRange(pendingChunks, messageStart, messageEnd));
322
- dropChunkFront(pendingChunks, messageEnd);
323
- pendingLen -= messageEnd;
324
-
225
+ for (const messageText of framer.drain(headerText => {
226
+ // Non-protocol bytes on stdout (e.g. a wrapper script printing).
227
+ // Drop past the bogus terminator and resync instead of stalling
228
+ // on the same junk header forever.
229
+ logger.warn("LSP framing resync: header block without Content-Length", {
230
+ server: client.name,
231
+ header: headerText.slice(0, 200),
232
+ });
233
+ })) {
325
234
  // A malformed message or a throwing server-request handler must not
326
235
  // kill the reader — later messages are still well-framed.
327
236
  try {
@@ -392,12 +301,7 @@ async function startMessageReader(client: LspClient): Promise<void> {
392
301
  client.pendingRequests.clear();
393
302
  } finally {
394
303
  // Persist any unparsed remainder so a restarted reader resumes mid-message.
395
- client.messageBuffer =
396
- pendingChunks.length === 0
397
- ? new Uint8Array(0)
398
- : pendingChunks.length === 1
399
- ? pendingChunks[0]
400
- : Buffer.concat(pendingChunks, pendingLen);
304
+ client.messageBuffer = framer.remainder();
401
305
  reader.releaseLock();
402
306
  client.isReading = false;
403
307
  // Reader exited while the server process is still alive (unrecoverable
@@ -1,208 +1,76 @@
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";
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
+ workerEnvFromParent,
14
+ } from "../subprocess/worker-client";
4
15
  import type { MnemopiEmbedModelId, MnemopiEmbedWorkerInbound, MnemopiEmbedWorkerOutbound } from "./embed-protocol";
5
16
 
6
17
  /**
7
- * Abstraction over the mnemopi embeddings subprocess. The runtime
18
+ * Parent-side handle for the mnemopi embeddings subprocess. The runtime
8
19
  * implementation is a Bun child process so `onnxruntime-node`'s NAPI
9
20
  * constructor + finalizer never run inside the main agent address space —
10
21
  * those destructors segfault Bun on Windows when mnemopi's local embedding
11
22
  * provider loads fastembed in the main process (issue #3031; the mnemopi
12
23
  * sibling of the tiny-model fix from #1606 / #1607).
13
24
  */
14
- export interface MnemopiEmbedWorkerHandle {
15
- send(message: MnemopiEmbedWorkerInbound): void;
16
- onMessage(handler: (message: MnemopiEmbedWorkerOutbound) => void): () => void;
17
- onError(handler: (error: Error) => void): () => void;
18
- terminate(): Promise<void>;
19
- }
25
+ export type MnemopiEmbedWorkerHandle = WorkerHandle<MnemopiEmbedWorkerInbound, MnemopiEmbedWorkerOutbound>;
20
26
 
21
27
  type PendingRequest =
22
28
  | { kind: "init"; model: MnemopiEmbedModelId; resolve: (ok: boolean) => void }
23
29
  | { kind: "embed"; model: MnemopiEmbedModelId; resolve: (vectors: number[][] | Error) => void };
24
30
 
25
- // Cold-starting the worker from a compiled binary (decompress + module graph load)
26
- // is slow on contended CI runners; the probe only proves the worker spawns and
27
- // ponges, so a generous bound removes flakes without weakening the check.
28
- const SMOKE_TEST_TIMEOUT_MS = 30_000;
29
-
30
31
  /**
31
32
  * Hidden subcommand on the main CLI that boots the mnemopi embeddings worker
32
33
  * in the spawned subprocess. Kept in sync with the dispatch in `cli.ts`.
33
34
  */
34
35
  export const MNEMOPI_EMBED_WORKER_ARG = "__omp_worker_mnemopi_embed";
35
36
 
36
- /**
37
- * Env handed to the embeddings subprocess. The child inherits the parent's
38
- * environment verbatim — fastembed honours `HF_HUB_*`, `HTTPS_PROXY`, etc.,
39
- * and our `loadFastembed()` reads the same `OMP_*` runtime-install knobs the
40
- * parent uses. `process.env` carries `undefined` slots that Bun.spawn rejects;
41
- * filter them out.
42
- */
43
- function mnemopiEmbedWorkerEnv(): Record<string, string> {
44
- const base = $env as Record<string, string | undefined>;
45
- const merged: Record<string, string> = {};
46
- for (const key in base) {
47
- const value = base[key];
48
- if (typeof value === "string") merged[key] = value;
49
- }
50
- return merged;
51
- }
52
-
53
- interface MnemopiEmbedWorkerSpawnCommand {
54
- cmd: string[];
55
- cwd?: string;
56
- }
57
-
58
- /**
59
- * Resolve the command used to relaunch the agent CLI into mnemopi-embed-worker
60
- * mode. In a compiled binary the entry point is the binary itself; otherwise
61
- * re-enter the declared worker-host entry (cwd-relative for reliable Bun IPC),
62
- * falling back to this package's own `src/cli.ts` when no host entry is
63
- * declared (bun test, SDK embedding).
64
- */
65
- function mnemopiEmbedWorkerSpawnCmd(): MnemopiEmbedWorkerSpawnCommand {
66
- if (isCompiledBinary()) return { cmd: [process.execPath, MNEMOPI_EMBED_WORKER_ARG] };
67
- const hostEntry = workerHostEntry();
68
- if (hostEntry) {
69
- return {
70
- cmd: [process.execPath, path.basename(hostEntry), MNEMOPI_EMBED_WORKER_ARG],
71
- cwd: path.dirname(hostEntry),
72
- };
73
- }
74
- const packageRoot = path.resolve(import.meta.dir, "..", "..");
75
- return { cmd: [process.execPath, "src/cli.ts", MNEMOPI_EMBED_WORKER_ARG], cwd: packageRoot };
76
- }
77
-
78
- interface SpawnedSubprocess {
79
- proc: Subprocess<"ignore", "ignore", "ignore">;
80
- inbound: Set<(message: MnemopiEmbedWorkerOutbound) => void>;
81
- errors: Set<(error: Error) => void>;
82
- /**
83
- * Flipped to `true` right before the deliberate SIGKILL so `onExit` can
84
- * distinguish the expected hard-kill from a crash (SIGSEGV from a native
85
- * fault, OOM SIGKILL, operator `kill -9`). Only the latter surfaces as a
86
- * worker error so callers don't await forever.
87
- */
88
- intentionalExit: { value: boolean };
89
- }
90
-
91
37
  /**
92
38
  * Spawn the mnemopi embeddings worker as a subprocess. Exported for tests and
93
39
  * the smoke probe; production callers go through {@link spawnMnemopiEmbedWorker}.
40
+ * The child inherits the parent env verbatim — fastembed honours `HF_HUB_*`,
41
+ * `HTTPS_PROXY`, etc., and our `loadFastembed()` reads the same `OMP_*`
42
+ * runtime-install knobs the parent uses.
94
43
  */
95
- export function createMnemopiEmbedSubprocess(): SpawnedSubprocess {
96
- const inbound = new Set<(message: MnemopiEmbedWorkerOutbound) => void>();
97
- const errors = new Set<(error: Error) => void>();
98
- const intentionalExit = { value: false };
99
- const spawnCommand = mnemopiEmbedWorkerSpawnCmd();
100
- const proc = Bun.spawn({
101
- cmd: spawnCommand.cmd,
102
- cwd: spawnCommand.cwd,
103
- env: mnemopiEmbedWorkerEnv(),
104
- stdin: "ignore",
105
- stdout: "ignore",
106
- stderr: "ignore",
107
- serialization: "advanced",
108
- windowsHide: true,
109
- ipc(message) {
110
- for (const handler of inbound) handler(message as MnemopiEmbedWorkerOutbound);
111
- },
112
- onExit(_proc, exitCode, signalCode) {
113
- if (exitCode === 0) return;
114
- if (exitCode === null && intentionalExit.value) return;
115
- const reason = exitCode !== null ? `code ${exitCode}` : `signal ${signalCode ?? "unknown"}`;
116
- const err = new Error(`mnemopi embed subprocess exited with ${reason}`);
117
- for (const handler of errors) handler(err);
118
- },
44
+ export function createMnemopiEmbedSubprocess(): SpawnedSubprocess<MnemopiEmbedWorkerOutbound> {
45
+ return createWorkerSubprocess<MnemopiEmbedWorkerOutbound>({
46
+ spawnCommand: resolveWorkerSpawnCmd(MNEMOPI_EMBED_WORKER_ARG),
47
+ env: workerEnvFromParent(),
48
+ exitLabel: "mnemopi embed subprocess",
119
49
  });
120
- // Don't keep the parent event loop alive on an idle worker; the agent
121
- // dispose path calls `terminate()` explicitly. Bun's test runner starves
122
- // IPC for unref'd subprocesses, so keep it referenced only under tests.
123
- if (!isBunTestRuntime()) proc.unref();
124
- return { proc, inbound, errors, intentionalExit };
125
- }
126
-
127
- function wrapSubprocess({ proc, inbound, errors, intentionalExit }: SpawnedSubprocess): MnemopiEmbedWorkerHandle {
128
- return {
129
- send(message) {
130
- try {
131
- proc.send(message);
132
- } catch (error) {
133
- logger.debug("mnemopi-embed: send to subprocess failed", {
134
- error: error instanceof Error ? error.message : String(error),
135
- });
136
- }
137
- },
138
- onMessage(handler) {
139
- inbound.add(handler);
140
- return () => inbound.delete(handler);
141
- },
142
- onError(handler) {
143
- errors.add(handler);
144
- return () => errors.delete(handler);
145
- },
146
- async terminate() {
147
- // SIGKILL: the point of subprocess isolation is that the parent
148
- // never runs `onnxruntime-node`'s NAPI finalizer (it crashes Bun
149
- // on Windows). Hard-kill instead; the OS reclaims the model
150
- // memory. Flip the intentional-exit flag *before* killing so
151
- // `onExit` can tell this apart from a native crash.
152
- intentionalExit.value = true;
153
- try {
154
- proc.kill("SIGKILL");
155
- } catch {
156
- // Already gone.
157
- }
158
- },
159
- };
160
50
  }
161
51
 
162
- function spawnInlineUnavailableWorker(error: unknown): MnemopiEmbedWorkerHandle {
163
- const listeners = new Set<(message: MnemopiEmbedWorkerOutbound) => void>();
164
- const errorMessage = error instanceof Error ? error.message : String(error);
165
- const emit = (message: MnemopiEmbedWorkerOutbound): void => {
166
- for (const listener of listeners) listener(message);
167
- };
168
- return {
169
- send(message) {
170
- queueMicrotask(() => {
171
- if (message.type === "ping") {
172
- emit({ type: "pong", id: message.id });
173
- return;
174
- }
175
- emit({ type: "error", id: message.id, error: errorMessage });
52
+ function wrapSubprocess(spawned: SpawnedSubprocess<MnemopiEmbedWorkerOutbound>): MnemopiEmbedWorkerHandle {
53
+ const { proc } = spawned;
54
+ // Embed keeps its own guarded `proc.send` (neutralizes only the synchronous
55
+ // throw, not the async EPIPE rejection) rather than the shared `safeSend`
56
+ // the other workers use — behaviour preserved verbatim.
57
+ return createWorkerHandle<MnemopiEmbedWorkerInbound, MnemopiEmbedWorkerOutbound>(spawned, message => {
58
+ try {
59
+ proc.send(message);
60
+ } catch (error) {
61
+ logger.debug("mnemopi-embed: send to subprocess failed", {
62
+ error: error instanceof Error ? error.message : String(error),
176
63
  });
177
- },
178
- onMessage(handler) {
179
- listeners.add(handler);
180
- return () => listeners.delete(handler);
181
- },
182
- onError() {
183
- return () => {};
184
- },
185
- async terminate() {
186
- listeners.clear();
187
- },
188
- };
64
+ }
65
+ });
189
66
  }
190
67
 
191
68
  function spawnMnemopiEmbedWorker(): MnemopiEmbedWorkerHandle {
192
- try {
193
- return wrapSubprocess(createMnemopiEmbedSubprocess());
194
- } catch (error) {
195
- logger.warn("mnemopi embed worker spawn failed; local embeddings disabled", {
196
- error: error instanceof Error ? error.message : String(error),
197
- });
198
- return spawnInlineUnavailableWorker(error);
199
- }
200
- }
201
-
202
- function logWorkerMessage(message: Extract<MnemopiEmbedWorkerOutbound, { type: "log" }>): void {
203
- if (message.level === "debug") logger.debug(message.msg, message.meta);
204
- else if (message.level === "warn") logger.warn(message.msg, message.meta);
205
- else logger.error(message.msg, message.meta);
69
+ return spawnWorkerOrUnavailable(
70
+ () => wrapSubprocess(createMnemopiEmbedSubprocess()),
71
+ createUnavailableWorker<MnemopiEmbedWorkerInbound, MnemopiEmbedWorkerOutbound>,
72
+ "mnemopi embed worker spawn failed; local embeddings disabled",
73
+ );
206
74
  }
207
75
 
208
76
  /**
@@ -374,28 +242,5 @@ export async function smokeTestMnemopiEmbedWorker({
374
242
  }: {
375
243
  timeoutMs?: number;
376
244
  } = {}): Promise<void> {
377
- const handle = wrapSubprocess(createMnemopiEmbedSubprocess());
378
- const { promise, resolve, reject } = Promise.withResolvers<void>();
379
- const timer = setTimeout(
380
- () => reject(new Error(`mnemopi embed worker did not pong within ${timeoutMs}ms`)),
381
- timeoutMs,
382
- );
383
- const unsubscribeMessage = handle.onMessage(message => {
384
- if (message.type === "pong") {
385
- resolve();
386
- return;
387
- }
388
- if (message.type === "log") return;
389
- reject(new Error(`mnemopi embed worker: expected pong, got ${JSON.stringify(message)}`));
390
- });
391
- const unsubscribeError = handle.onError(reject);
392
- try {
393
- handle.send({ type: "ping", id: "smoke" } satisfies MnemopiEmbedWorkerInbound);
394
- await promise;
395
- } finally {
396
- clearTimeout(timer);
397
- unsubscribeMessage();
398
- unsubscribeError();
399
- await handle.terminate();
400
- }
245
+ await smokeTestWorker(wrapSubprocess(createMnemopiEmbedSubprocess()), "mnemopi embed worker", timeoutMs);
401
246
  }
@@ -21,7 +21,6 @@ import {
21
21
  type Component,
22
22
  Container,
23
23
  Editor,
24
- extractPrintableText,
25
24
  fuzzyMatch,
26
25
  Input,
27
26
  matchesKey,
@@ -59,6 +58,7 @@ import {
59
58
  matchesSelectUp,
60
59
  } from "../utils/keybinding-matchers";
61
60
  import { DynamicBorder } from "./dynamic-border";
61
+ import { clampSelection, handleTabSwitchKey, padLinesToHeight, searchableChar } from "./selector-helpers";
62
62
 
63
63
  type SourceTabId = "all" | AgentSource;
64
64
  type AgentScope = "project" | "user";
@@ -521,31 +521,19 @@ export class AgentDashboard extends Container {
521
521
  const lines = super.render(width);
522
522
  // Pad to the full viewport so every state (list, edit, create) covers the
523
523
  // screen as a true full-screen view instead of letting the transcript peek
524
- // through below it. Copy before padding — the container's render result is
525
- // component-owned and must not be mutated.
526
- const rows = this.#terminalRows();
527
- if (lines.length >= rows) return lines;
528
- const padded = lines.slice();
529
- while (padded.length < rows) padded.push("");
530
- return padded;
524
+ // through below it.
525
+ return padLinesToHeight(lines, this.#terminalRows());
531
526
  }
532
527
 
533
528
  #clampSelection(): void {
534
- if (this.#filteredAgents.length === 0) {
535
- this.#selectedIndex = 0;
536
- this.#scrollOffset = 0;
537
- return;
538
- }
539
-
540
- this.#selectedIndex = Math.min(this.#selectedIndex, this.#filteredAgents.length - 1);
541
- this.#selectedIndex = Math.max(0, this.#selectedIndex);
542
-
543
- const maxVisible = this.#getMaxVisibleItems();
544
- if (this.#selectedIndex < this.#scrollOffset) {
545
- this.#scrollOffset = this.#selectedIndex;
546
- } else if (this.#selectedIndex >= this.#scrollOffset + maxVisible) {
547
- this.#scrollOffset = this.#selectedIndex - maxVisible + 1;
548
- }
529
+ const next = clampSelection(
530
+ this.#selectedIndex,
531
+ this.#scrollOffset,
532
+ this.#filteredAgents.length,
533
+ this.#getMaxVisibleItems(),
534
+ );
535
+ this.#selectedIndex = next.selectedIndex;
536
+ this.#scrollOffset = next.scrollOffset;
549
537
  }
550
538
 
551
539
  #persistDisabledAgents(): void {
@@ -1150,12 +1138,7 @@ export class AgentDashboard extends Container {
1150
1138
  return;
1151
1139
  }
1152
1140
 
1153
- if (matchesKey(data, "tab") || matchesKey(data, "right")) {
1154
- this.#switchTab(1);
1155
- return;
1156
- }
1157
- if (matchesKey(data, "shift+tab") || matchesKey(data, "left")) {
1158
- this.#switchTab(-1);
1141
+ if (handleTabSwitchKey(data, direction => this.#switchTab(direction))) {
1159
1142
  return;
1160
1143
  }
1161
1144
 
@@ -1190,17 +1173,11 @@ export class AgentDashboard extends Container {
1190
1173
  return;
1191
1174
  }
1192
1175
 
1193
- const printableText = extractPrintableText(data);
1194
- if (printableText && printableText.length === 1) {
1195
- const printableCharCode = printableText.charCodeAt(0);
1196
- if (printableCharCode > 32 && printableCharCode < 127) {
1197
- if (printableText === "j" || printableText === "k") {
1198
- return;
1199
- }
1200
- this.#searchQuery += printableText;
1201
- this.#applyFilters();
1202
- this.#buildLayout();
1203
- }
1176
+ const char = searchableChar(data);
1177
+ if (char !== null) {
1178
+ this.#searchQuery += char;
1179
+ this.#applyFilters();
1180
+ this.#buildLayout();
1204
1181
  }
1205
1182
  }
1206
1183
  }