@oh-my-pi/pi-coding-agent 15.7.3 → 15.7.6

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 (119) hide show
  1. package/CHANGELOG.md +69 -1
  2. package/dist/types/config/settings-schema.d.ts +12 -22
  3. package/dist/types/eval/__tests__/heartbeat.test.d.ts +1 -0
  4. package/dist/types/eval/heartbeat.d.ts +45 -0
  5. package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
  6. package/dist/types/extensibility/extensions/loader.d.ts +2 -2
  7. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  8. package/dist/types/extensibility/extensions/types.d.ts +24 -5
  9. package/dist/types/extensibility/shared-events.d.ts +2 -2
  10. package/dist/types/internal-urls/local-protocol.d.ts +19 -9
  11. package/dist/types/internal-urls/types.d.ts +14 -0
  12. package/dist/types/lsp/client.d.ts +3 -0
  13. package/dist/types/lsp/diagnostics-ledger.d.ts +10 -0
  14. package/dist/types/lsp/index.d.ts +2 -0
  15. package/dist/types/lsp/utils.d.ts +4 -0
  16. package/dist/types/mcp/manager.d.ts +14 -5
  17. package/dist/types/modes/acp/acp-agent.d.ts +1 -1
  18. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  19. package/dist/types/modes/components/custom-editor.d.ts +0 -1
  20. package/dist/types/modes/components/hook-selector.d.ts +7 -1
  21. package/dist/types/modes/controllers/command-controller.d.ts +2 -3
  22. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -2
  23. package/dist/types/modes/interactive-mode.d.ts +2 -2
  24. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  25. package/dist/types/modes/theme/theme.d.ts +1 -1
  26. package/dist/types/modes/types.d.ts +2 -2
  27. package/dist/types/session/agent-session.d.ts +10 -7
  28. package/dist/types/session/shake-types.d.ts +3 -3
  29. package/dist/types/task/repair-args.d.ts +52 -0
  30. package/dist/types/tiny/models.d.ts +0 -14
  31. package/dist/types/tiny/title-client.d.ts +28 -2
  32. package/dist/types/tiny/title-protocol.d.ts +8 -9
  33. package/dist/types/tools/ask.d.ts +8 -6
  34. package/dist/types/tools/eval-backends.d.ts +12 -0
  35. package/dist/types/tools/eval-render.d.ts +52 -0
  36. package/dist/types/tools/eval.d.ts +2 -35
  37. package/dist/types/tools/find.d.ts +1 -1
  38. package/dist/types/tools/index.d.ts +4 -11
  39. package/dist/types/tools/path-utils.d.ts +7 -0
  40. package/dist/types/tui/output-block.d.ts +11 -10
  41. package/examples/extensions/README.md +1 -0
  42. package/examples/extensions/thinking-note.ts +13 -0
  43. package/package.json +9 -9
  44. package/scripts/build-binary.ts +0 -1
  45. package/src/cli.ts +59 -0
  46. package/src/config/model-registry.ts +33 -4
  47. package/src/config/settings-schema.ts +13 -24
  48. package/src/config/settings.ts +10 -0
  49. package/src/discovery/claude.ts +41 -22
  50. package/src/edit/index.ts +23 -3
  51. package/src/eval/__tests__/agent-bridge.test.ts +90 -0
  52. package/src/eval/__tests__/heartbeat.test.ts +84 -0
  53. package/src/eval/__tests__/llm-bridge.test.ts +30 -0
  54. package/src/eval/agent-bridge.ts +44 -38
  55. package/src/eval/heartbeat.ts +74 -0
  56. package/src/eval/js/executor.ts +13 -9
  57. package/src/eval/llm-bridge.ts +20 -14
  58. package/src/eval/py/executor.ts +14 -18
  59. package/src/exec/bash-executor.ts +31 -5
  60. package/src/extensibility/custom-tools/types.ts +2 -2
  61. package/src/extensibility/extensions/loader.ts +16 -18
  62. package/src/extensibility/extensions/runner.ts +22 -17
  63. package/src/extensibility/extensions/types.ts +39 -5
  64. package/src/extensibility/shared-events.ts +2 -2
  65. package/src/internal-urls/docs-index.generated.ts +5 -5
  66. package/src/internal-urls/local-protocol.ts +23 -11
  67. package/src/internal-urls/types.ts +15 -0
  68. package/src/lsp/client.ts +28 -5
  69. package/src/lsp/diagnostics-ledger.ts +51 -0
  70. package/src/lsp/index.ts +9 -22
  71. package/src/lsp/utils.ts +21 -0
  72. package/src/mcp/manager.ts +87 -4
  73. package/src/modes/acp/acp-agent.ts +8 -4
  74. package/src/modes/components/assistant-message.ts +28 -1
  75. package/src/modes/components/custom-editor.ts +9 -7
  76. package/src/modes/components/hook-selector.ts +159 -32
  77. package/src/modes/components/tool-execution.ts +20 -4
  78. package/src/modes/controllers/command-controller.ts +7 -39
  79. package/src/modes/controllers/event-controller.ts +38 -28
  80. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  81. package/src/modes/controllers/input-controller.ts +0 -15
  82. package/src/modes/controllers/mcp-command-controller.ts +1 -1
  83. package/src/modes/interactive-mode.ts +2 -1
  84. package/src/modes/rpc/rpc-mode.ts +17 -6
  85. package/src/modes/theme/theme-schema.json +30 -0
  86. package/src/modes/theme/theme.ts +39 -2
  87. package/src/modes/types.ts +2 -1
  88. package/src/modes/utils/ui-helpers.ts +5 -2
  89. package/src/prompts/system/project-prompt.md +3 -2
  90. package/src/prompts/system/subagent-system-prompt.md +12 -8
  91. package/src/prompts/system/system-prompt.md +8 -6
  92. package/src/prompts/tools/ask.md +2 -1
  93. package/src/prompts/tools/eval.md +1 -1
  94. package/src/session/agent-session.ts +75 -103
  95. package/src/session/shake-types.ts +4 -5
  96. package/src/slash-commands/builtin-registry.ts +2 -4
  97. package/src/task/executor.ts +14 -4
  98. package/src/task/index.ts +3 -2
  99. package/src/task/repair-args.ts +117 -0
  100. package/src/tiny/models.ts +0 -28
  101. package/src/tiny/title-client.ts +133 -43
  102. package/src/tiny/title-protocol.ts +11 -16
  103. package/src/tiny/worker.ts +6 -61
  104. package/src/tools/ask.ts +74 -32
  105. package/src/tools/ast-edit.ts +3 -0
  106. package/src/tools/ast-grep.ts +3 -0
  107. package/src/tools/eval-backends.ts +38 -0
  108. package/src/tools/eval-render.ts +750 -0
  109. package/src/tools/eval.ts +27 -754
  110. package/src/tools/find.ts +20 -6
  111. package/src/tools/gh.ts +1 -0
  112. package/src/tools/index.ts +7 -37
  113. package/src/tools/path-utils.ts +13 -2
  114. package/src/tools/read.ts +1 -0
  115. package/src/tools/renderers.ts +1 -1
  116. package/src/tools/search.ts +12 -1
  117. package/src/tools/write.ts +9 -1
  118. package/src/tui/output-block.ts +42 -79
  119. package/src/utils/git.ts +9 -3
@@ -62,9 +62,8 @@ const shutdownHandlerTui = (_command: ParsedSlashCommand, runtime: TuiSlashComma
62
62
  function parseShakeMode(args: string): ShakeMode | { error: string } {
63
63
  const verb = args.trim().toLowerCase();
64
64
  if (verb === "" || verb === "elide") return "elide";
65
- if (verb === "summary") return "summary";
66
65
  if (verb === "images") return "images";
67
- return { error: `Unknown /shake mode "${verb}". Use elide, summary, or images.` };
66
+ return { error: `Unknown /shake mode "${verb}". Use elide or images.` };
68
67
  }
69
68
 
70
69
  const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
@@ -826,10 +825,9 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
826
825
  acpDescription: "Shake heavy content out of the conversation context",
827
826
  subcommands: [
828
827
  { name: "elide", description: "Strip tool results + large blocks (default)" },
829
- { name: "summary", description: "Compress heavy regions with a local on-device model" },
830
828
  { name: "images", description: "Strip image blocks" },
831
829
  ],
832
- acpInputHint: "[elide|summary|images]",
830
+ acpInputHint: "[elide|images]",
833
831
  allowArgs: true,
834
832
  handle: async (command, runtime) => {
835
833
  const mode = parseShakeMode(command.args);
@@ -633,7 +633,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
633
633
  if (atMaxDepth && toolNames?.includes("task")) {
634
634
  toolNames = toolNames.filter(name => name !== "task");
635
635
  }
636
- // IRC is always available; the [COOP] prompt advertises it, so a restricted
636
+ // IRC is always available; the COOP prompt section advertises it, so a restricted
637
637
  // whitelist must still carry `irc` for the subagent to actually use it.
638
638
  if (toolNames && !toolNames.includes("irc")) {
639
639
  toolNames = [...toolNames, "irc"];
@@ -1446,9 +1446,19 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1446
1446
  );
1447
1447
  await awaitAbortable(session.waitForIdle());
1448
1448
  } catch (err) {
1449
- logger.error("Subagent prompt failed", {
1450
- error: err instanceof Error ? err.message : String(err),
1451
- });
1449
+ if (abortSignal.aborted || err instanceof ToolAbortError) {
1450
+ // Benign control-flow exit user cancel (^C) or compaction aborting
1451
+ // pending operations both surface here as ToolAbortError. The outer
1452
+ // catch and finally already mark the run aborted; logging at ERROR
1453
+ // would spam operator dashboards with non-failures.
1454
+ logger.debug("Subagent prompt aborted", {
1455
+ reason: abortReason ?? "signal",
1456
+ });
1457
+ } else {
1458
+ logger.error("Subagent prompt failed", {
1459
+ error: err instanceof Error ? err.message : String(err),
1460
+ });
1461
+ }
1452
1462
  }
1453
1463
  }
1454
1464
 
package/src/task/index.ts CHANGED
@@ -48,6 +48,7 @@ import { runSubprocess } from "./executor";
48
48
  import { AgentOutputManager } from "./output-manager";
49
49
  import { mapWithConcurrencyLimit, Semaphore } from "./parallel";
50
50
  import { renderResult, renderCall as renderTaskCall } from "./render";
51
+ import { repairTaskParams } from "./repair-args";
51
52
  import { getTaskSimpleModeCapabilities, type TaskSimpleMode } from "./simple-mode";
52
53
  import {
53
54
  applyNestedPatches,
@@ -247,7 +248,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
247
248
  }
248
249
 
249
250
  renderCall(args: unknown, options: Parameters<typeof renderTaskCall>[1], theme: Theme) {
250
- return renderTaskCall(args as TaskParams, options, theme);
251
+ return renderTaskCall(repairTaskParams(args as TaskParams), options, theme);
251
252
  }
252
253
 
253
254
  /** Dynamic description that reflects current disabled-agent settings */
@@ -292,7 +293,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
292
293
  signal?: AbortSignal,
293
294
  onUpdate?: AgentToolUpdateCallback<TaskToolDetails>,
294
295
  ): Promise<AgentToolResult<TaskToolDetails>> {
295
- const params = rawParams as TaskParams;
296
+ const params = repairTaskParams(rawParams as TaskParams);
296
297
  const simpleMode = this.#getTaskSimpleMode();
297
298
  const validationError = validateTaskModeParams(simpleMode, params);
298
299
  if (validationError) {
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Repair double-encoded JSON string arguments for the task tool.
3
+ *
4
+ * Models occasionally JSON-escape a string value twice when emitting a
5
+ * `task` tool call, so a `context`/`assignment` that should read
6
+ *
7
+ * # Role
8
+ * You are a judge … "describe this" … return —
9
+ *
10
+ * arrives — after the one JSON decode the provider already applied — as the
11
+ * literal text
12
+ *
13
+ * # Role\nYou are a judge … \"describe this\" … return \u2014
14
+ *
15
+ * i.e. every newline, quote, and unicode character is still backslash-escaped.
16
+ * The subagent then receives that garbled prompt, and the call preview renders
17
+ * one long blob with visible `\n` / `\"` / `\uXXXX`.
18
+ *
19
+ * The *whole-arguments* form of this quirk (the entire `arguments` blob is a
20
+ * JSON string) is already auto-corrected by the validator's JSON-string
21
+ * coercion. This module handles the *per-field* form, where the object parses
22
+ * fine but an individual string value is double-encoded — the validator never
23
+ * fires there because a double-encoded string is still a structurally valid
24
+ * string.
25
+ *
26
+ * This is deliberately scoped to the task tool's natural-language fields
27
+ * (`context`, `assignment`, `description`). It is NOT applied to code-bearing
28
+ * tools (write/edit/bash/search), where a backslash or quote is load-bearing
29
+ * and a false-positive unescape would silently corrupt a file or command.
30
+ */
31
+ import type { TaskItem, TaskParams } from "./types";
32
+
33
+ /** A backslash that escapes a structural char — `\"`, `\\`, `\/`, or `\uXXXX`. */
34
+ const STRUCTURAL_ESCAPE = /\\(?:["\\/]|u[0-9a-fA-F]{4})/;
35
+
36
+ /**
37
+ * Whether `value` carries the signature of whole-string double-encoding rather
38
+ * than an incidental escape mention. A lone `\n`/`\t` in an instruction (e.g.
39
+ * "split lines on \n") is far more likely a literal mention than a
40
+ * double-encoded document, so it is left alone; a structural escape (`\"`,
41
+ * `\\`, `\uXXXX`) or two-plus escape sequences indicates a re-escaped payload.
42
+ */
43
+ function hasDoubleEncodeSignature(value: string): boolean {
44
+ if (STRUCTURAL_ESCAPE.test(value)) return true;
45
+ let count = 0;
46
+ for (let i = 0; i < value.length; i++) {
47
+ if (value.charCodeAt(i) === 0x5c /* \ */) {
48
+ count += 1;
49
+ if (count >= 2) return true;
50
+ i += 1; // skip the escaped char so `\\` counts once
51
+ }
52
+ }
53
+ return false;
54
+ }
55
+
56
+ /**
57
+ * Return the once-unescaped string when `value` is uniformly double-encoded
58
+ * JSON (a well-formed JSON string body that decodes to a different string);
59
+ * otherwise return `value` unchanged.
60
+ *
61
+ * The `JSON.parse(\`"${value}"\`)` round-trip is the safety net: it only
62
+ * succeeds when *every* backslash begins a valid JSON escape and no bare
63
+ * double-quote exists — exactly the signature of double-encoding. Genuine
64
+ * prose with a Windows path (`C:\Users`), a regex (`\d+`), an embedded quote,
65
+ * or a real (already-decoded) newline makes the parse throw, so the value is
66
+ * returned untouched.
67
+ */
68
+ export function repairDoubleEncodedJsonString(value: string): string {
69
+ // Fast path: no backslash → nothing was escaped → the parse can never differ.
70
+ if (!value.includes("\\")) return value;
71
+ if (!hasDoubleEncodeSignature(value)) return value;
72
+ let decoded: unknown;
73
+ try {
74
+ decoded = JSON.parse(`"${value}"`);
75
+ } catch {
76
+ return value;
77
+ }
78
+ return typeof decoded === "string" && decoded !== value ? decoded : value;
79
+ }
80
+
81
+ /** Repair a single (possibly partial) task item's prose fields. */
82
+ function repairTaskItem(task: TaskItem): TaskItem {
83
+ if (task === null || typeof task !== "object") return task;
84
+ const assignment =
85
+ typeof task.assignment === "string" ? repairDoubleEncodedJsonString(task.assignment) : task.assignment;
86
+ const description =
87
+ typeof task.description === "string" ? repairDoubleEncodedJsonString(task.description) : task.description;
88
+ if (assignment === task.assignment && description === task.description) return task;
89
+ return { ...task, assignment, description };
90
+ }
91
+
92
+ /**
93
+ * Repair double-encoded prose in task-tool params (`context` and each task's
94
+ * `assignment`/`description`). Returns the same reference when nothing changed
95
+ * so callers can cheaply skip work. Defensive against partially-streamed args
96
+ * (missing/undefined fields, partial task arrays) so it is safe on the render
97
+ * path as well as on execution.
98
+ */
99
+ export function repairTaskParams(params: TaskParams): TaskParams {
100
+ if (params === null || typeof params !== "object") return params;
101
+
102
+ const context = typeof params.context === "string" ? repairDoubleEncodedJsonString(params.context) : params.context;
103
+
104
+ let tasks = params.tasks;
105
+ if (Array.isArray(params.tasks)) {
106
+ let changed = false;
107
+ const repaired = params.tasks.map(task => {
108
+ const next = repairTaskItem(task);
109
+ if (next !== task) changed = true;
110
+ return next;
111
+ });
112
+ if (changed) tasks = repaired;
113
+ }
114
+
115
+ if (context === params.context && tasks === params.tasks) return params;
116
+ return { ...params, context, tasks };
117
+ }
@@ -196,34 +196,6 @@ export function getTinyMemoryModelSpec(key: TinyMemoryLocalModelKey): (typeof TI
196
196
  return spec;
197
197
  }
198
198
 
199
- /**
200
- * Shake-summary models. Shake's `summary` mode (and the `shake-summary`
201
- * compaction strategy) compress heavy regions strictly on-device — there is no
202
- * online/remote option, so this registry reuses the local memory models only.
203
- */
204
- export const SHAKE_SUMMARY_MODEL_VALUES = [
205
- "qwen3-1.7b",
206
- "gemma-3-1b",
207
- "qwen2.5-1.5b",
208
- "lfm2-1.2b",
209
- ] as const satisfies readonly TinyMemoryLocalModelKey[];
210
-
211
- export type ShakeSummaryModelKey = (typeof SHAKE_SUMMARY_MODEL_VALUES)[number];
212
-
213
- // Guard: every local memory model is offered for shake summary (catches drift).
214
- type MissingShakeSummaryValue = Exclude<TinyMemoryLocalModelKey, ShakeSummaryModelKey>;
215
- const SHAKE_SUMMARY_MODEL_VALUES_MATCH_REGISTRY: MissingShakeSummaryValue extends never ? true : never = true;
216
- void SHAKE_SUMMARY_MODEL_VALUES_MATCH_REGISTRY;
217
-
218
- export const SHAKE_SUMMARY_MODEL_OPTIONS = TINY_MEMORY_LOCAL_MODELS.map(model => ({
219
- value: model.key,
220
- label: model.label,
221
- description: model.description,
222
- })) satisfies ReadonlyArray<{ value: ShakeSummaryModelKey; label: string; description: string }>;
223
-
224
- /** Default shake-summary local model when none is named. */
225
- export const DEFAULT_SHAKE_SUMMARY_MODEL_KEY: ShakeSummaryModelKey = DEFAULT_MEMORY_LOCAL_MODEL_KEY;
226
-
227
199
  /** Any local model key (title or memory), used by the shared inference worker. */
228
200
  export type TinyLocalModelKey = TinyTitleLocalModelKey | TinyMemoryLocalModelKey;
229
201
 
@@ -1,4 +1,6 @@
1
+ import * as path from "node:path";
1
2
  import { $env, isCompiledBinary, logger } from "@oh-my-pi/pi-utils";
3
+ import type { Subprocess } from "bun";
2
4
  import { settings } from "../config/settings";
3
5
  import { tinyModelDeviceSettingToEnv } from "./device";
4
6
  import { tinyModelDtypeSettingToEnv } from "./dtype";
@@ -12,6 +14,14 @@ import {
12
14
  } from "./models";
13
15
  import type { TinyTitleProgressEvent, TinyTitleWorkerInbound, TinyTitleWorkerOutbound } from "./title-protocol";
14
16
 
17
+ /**
18
+ * Abstraction over the tiny-model subprocess. Modelled as a worker interface
19
+ * so existing callers (titles, memory completions, downloads) compose the
20
+ * same way; the runtime implementation is a Bun child process so
21
+ * `onnxruntime-node`'s NAPI finalizer never runs inside the main agent
22
+ * address space — that destructor segfaults Bun on Windows during shutdown
23
+ * (issue #1606).
24
+ */
15
25
  interface WorkerHandle {
16
26
  send(message: TinyTitleWorkerInbound): void;
17
27
  onMessage(handler: (message: TinyTitleWorkerOutbound) => void): () => void;
@@ -31,6 +41,12 @@ export interface TinyTitleDownloadOptions {
31
41
 
32
42
  const SMOKE_TEST_TIMEOUT_MS = 5_000;
33
43
 
44
+ /**
45
+ * Hidden subcommand on the main CLI that boots the tiny-model worker in the
46
+ * spawned subprocess. Kept in sync with the dispatch in `cli.ts`.
47
+ */
48
+ export const TINY_WORKER_ARG = "--tiny-worker";
49
+
34
50
  function readTinyModelSetting(path: "providers.tinyModelDevice" | "providers.tinyModelDtype"): string | undefined {
35
51
  try {
36
52
  const value = settings.get(path);
@@ -66,49 +82,128 @@ export function tinyWorkerEnvOverlay(
66
82
  }
67
83
 
68
84
  /**
69
- * Env handed to the tiny-model worker. The `PI_TINY_DEVICE` / `PI_TINY_DTYPE` env
70
- * vars win; otherwise the persisted `providers.tinyModelDevice` /
71
- * `providers.tinyModelDtype` settings are mapped onto those vars so the worker's
72
- * env-based resolution picks them up. Resolved once at spawn (pipelines are cached).
85
+ * Env handed to the tiny-model subprocess. The `PI_TINY_DEVICE` / `PI_TINY_DTYPE`
86
+ * env vars win; otherwise the persisted `providers.tinyModelDevice` /
87
+ * `providers.tinyModelDtype` settings are mapped onto those vars so the
88
+ * subprocess's env-based resolution picks them up. Resolved once at spawn
89
+ * (pipelines are cached for the lifetime of the subprocess).
73
90
  */
74
- function tinyWorkerEnv(): Record<string, string> | undefined {
91
+ function tinyWorkerEnv(): Record<string, string> {
75
92
  const overlay = tinyWorkerEnvOverlay(
76
93
  $env,
77
94
  readTinyModelSetting("providers.tinyModelDevice"),
78
95
  readTinyModelSetting("providers.tinyModelDtype"),
79
96
  );
80
- if (Object.keys(overlay).length === 0) return undefined;
81
- return { ...($env as Record<string, string>), ...overlay };
97
+ const base = $env as Record<string, string | undefined>;
98
+ const merged: Record<string, string> = {};
99
+ for (const key in base) {
100
+ const value = base[key];
101
+ if (typeof value === "string") merged[key] = value;
102
+ }
103
+ for (const key in overlay) merged[key] = overlay[key];
104
+ return merged;
82
105
  }
83
106
 
84
- export function createTinyTitleWorker(): Worker {
85
- const env = tinyWorkerEnv();
86
- const options: WorkerOptions = env ? { type: "module", env } : { type: "module" };
87
- return isCompiledBinary()
88
- ? new Worker("./packages/coding-agent/src/tiny/worker.ts", options)
89
- : new Worker(new URL("./worker.ts", import.meta.url).href, options);
107
+ /**
108
+ * Resolve the argv used to relaunch the agent CLI into tiny-worker mode. In a
109
+ * compiled binary the entry point is the binary itself; in dev/source the
110
+ * spawned `bun` needs the absolute path to `cli.ts` so it can resolve module
111
+ * imports against the on-disk source tree.
112
+ */
113
+ function tinyWorkerSpawnCmd(): string[] {
114
+ if (isCompiledBinary()) return [process.execPath, TINY_WORKER_ARG];
115
+ const cliPath = path.resolve(import.meta.dir, "..", "cli.ts");
116
+ return [process.execPath, cliPath, TINY_WORKER_ARG];
117
+ }
118
+
119
+ interface SpawnedSubprocess {
120
+ proc: Subprocess<"ignore", "inherit", "inherit">;
121
+ inbound: Set<(message: TinyTitleWorkerOutbound) => void>;
122
+ errors: Set<(error: Error) => void>;
123
+ /**
124
+ * Flipped to `true` by {@link wrapSubprocess}'s `terminate()` right
125
+ * before it SIGKILLs the child so `onExit` can distinguish the
126
+ * expected hard-kill from a crash/OOM/external signal. Only the
127
+ * latter is surfaced as a worker error.
128
+ */
129
+ intentionalExit: { value: boolean };
90
130
  }
91
131
 
92
- function wrapBunWorker(worker: Worker): WorkerHandle {
93
- (worker as Worker & { unref?: () => void }).unref?.();
132
+ /**
133
+ * Spawn the tiny-model worker as a subprocess. Exported for tests and the
134
+ * smoke probe; production callers go through {@link spawnTinyTitleWorker}
135
+ * which wraps the result in a {@link WorkerHandle}.
136
+ */
137
+ export function createTinyTitleSubprocess(): SpawnedSubprocess {
138
+ const inbound = new Set<(message: TinyTitleWorkerOutbound) => void>();
139
+ const errors = new Set<(error: Error) => void>();
140
+ const intentionalExit = { value: false };
141
+ const proc = Bun.spawn({
142
+ cmd: tinyWorkerSpawnCmd(),
143
+ env: tinyWorkerEnv(),
144
+ stdin: "ignore",
145
+ stdout: "inherit",
146
+ stderr: "inherit",
147
+ serialization: "advanced",
148
+ windowsHide: true,
149
+ ipc(message) {
150
+ for (const handler of inbound) handler(message as TinyTitleWorkerOutbound);
151
+ },
152
+ onExit(_proc, exitCode, signalCode) {
153
+ // Clean exit. The child only exits via SIGKILL in practice, but
154
+ // treat code 0 as a no-op for symmetry.
155
+ if (exitCode === 0) return;
156
+ // `exitCode === null` + non-null `signalCode` covers both the
157
+ // expected SIGKILL from `terminate()` AND external kills
158
+ // (SIGSEGV from a native crash, SIGKILL from the OOM killer, an
159
+ // operator `kill -9`, etc.). Swallow only the expected one;
160
+ // every other signal exit is a real worker death that must
161
+ // fault every in-flight request so callers don't await forever.
162
+ if (exitCode === null && intentionalExit.value) return;
163
+ const reason = exitCode !== null ? `code ${exitCode}` : `signal ${signalCode ?? "unknown"}`;
164
+ const err = new Error(`tiny model subprocess exited with ${reason}`);
165
+ for (const handler of errors) handler(err);
166
+ },
167
+ });
168
+ // Don't keep the parent event loop alive on account of an idle worker; the
169
+ // agent dispose path calls `terminate()` explicitly when shutting down.
170
+ proc.unref();
171
+ return { proc, inbound, errors, intentionalExit };
172
+ }
173
+
174
+ function wrapSubprocess({ proc, inbound, errors, intentionalExit }: SpawnedSubprocess): WorkerHandle {
94
175
  return {
95
176
  send(message) {
96
- worker.postMessage(message);
177
+ try {
178
+ proc.send(message);
179
+ } catch (error) {
180
+ logger.debug("tiny-title: send to subprocess failed", {
181
+ error: error instanceof Error ? error.message : String(error),
182
+ });
183
+ }
97
184
  },
98
185
  onMessage(handler) {
99
- const wrap = (event: MessageEvent): void => handler(event.data as TinyTitleWorkerOutbound);
100
- worker.addEventListener("message", wrap);
101
- return () => worker.removeEventListener("message", wrap);
186
+ inbound.add(handler);
187
+ return () => inbound.delete(handler);
102
188
  },
103
189
  onError(handler) {
104
- const wrap = (event: ErrorEvent): void => {
105
- handler(event.error instanceof Error ? event.error : new Error(event.message || "tiny title worker error"));
106
- };
107
- worker.addEventListener("error", wrap);
108
- return () => worker.removeEventListener("error", wrap);
190
+ errors.add(handler);
191
+ return () => errors.delete(handler);
109
192
  },
110
193
  async terminate() {
111
- worker.terminate();
194
+ // SIGKILL: the whole point of the subprocess isolation is that the
195
+ // parent never runs `onnxruntime-node`'s NAPI finalizer. A polite
196
+ // SIGTERM lets the subprocess try to clean up, which is exactly the
197
+ // codepath that crashes Bun on Windows. Hard-kill instead — the
198
+ // model lives in process memory and the OS reclaims everything.
199
+ // Flip the intentional-exit flag *before* killing so `onExit` can
200
+ // tell this apart from a crash or external SIGKILL.
201
+ intentionalExit.value = true;
202
+ try {
203
+ proc.kill("SIGKILL");
204
+ } catch {
205
+ // Already gone.
206
+ }
112
207
  },
113
208
  };
114
209
  }
@@ -126,10 +221,6 @@ function spawnInlineUnavailableWorker(error: unknown): WorkerHandle {
126
221
  emit({ type: "pong", id: message.id });
127
222
  return;
128
223
  }
129
- if (message.type === "close") {
130
- emit({ type: "closed" });
131
- return;
132
- }
133
224
  emit({ type: "error", id: message.id, error: errorMessage });
134
225
  });
135
226
  },
@@ -148,9 +239,9 @@ function spawnInlineUnavailableWorker(error: unknown): WorkerHandle {
148
239
 
149
240
  function spawnTinyTitleWorker(): WorkerHandle {
150
241
  try {
151
- return wrapBunWorker(createTinyTitleWorker());
242
+ return wrapSubprocess(createTinyTitleSubprocess());
152
243
  } catch (error) {
153
- logger.warn("Tiny title Worker spawn failed; local titles disabled", {
244
+ logger.warn("Tiny title worker spawn failed; local titles disabled", {
154
245
  error: error instanceof Error ? error.message : String(error),
155
246
  });
156
247
  return spawnInlineUnavailableWorker(error);
@@ -293,9 +384,9 @@ export class TinyTitleClient {
293
384
  }
294
385
  this.#pending.clear();
295
386
  try {
296
- worker?.send({ type: "close" });
387
+ await worker?.terminate();
297
388
  } catch {
298
- // Worker may already be gone.
389
+ // Already gone.
299
390
  }
300
391
  }
301
392
 
@@ -317,7 +408,6 @@ export class TinyTitleClient {
317
408
  this.#emitProgress(message.event);
318
409
  return;
319
410
  }
320
- if (message.type === "closed") return;
321
411
  if (message.type === "pong") return;
322
412
 
323
413
  const pending = this.#pending.get(message.id);
@@ -371,25 +461,25 @@ export async function smokeTestTinyTitleWorker({
371
461
  }: {
372
462
  timeoutMs?: number;
373
463
  } = {}): Promise<void> {
374
- const worker = createTinyTitleWorker();
464
+ const handle = wrapSubprocess(createTinyTitleSubprocess());
375
465
  const { promise, resolve, reject } = Promise.withResolvers<void>();
376
466
  const timer = setTimeout(() => reject(new Error(`tiny title worker did not pong within ${timeoutMs}ms`)), timeoutMs);
377
- worker.onmessage = (event: MessageEvent<TinyTitleWorkerOutbound>) => {
378
- const message = event.data;
467
+ const unsubscribeMessage = handle.onMessage(message => {
379
468
  if (message.type === "pong") {
380
469
  resolve();
381
470
  return;
382
471
  }
472
+ if (message.type === "log") return;
383
473
  reject(new Error(`tiny title worker: expected pong, got ${JSON.stringify(message)}`));
384
- };
385
- worker.onerror = (event: ErrorEvent) => {
386
- reject(event.error instanceof Error ? event.error : new Error(event.message || "tiny title worker error"));
387
- };
474
+ });
475
+ const unsubscribeError = handle.onError(reject);
388
476
  try {
389
- worker.postMessage({ type: "ping", id: "smoke" } satisfies TinyTitleWorkerInbound);
477
+ handle.send({ type: "ping", id: "smoke" } satisfies TinyTitleWorkerInbound);
390
478
  await promise;
391
479
  } finally {
392
480
  clearTimeout(timer);
393
- worker.terminate();
481
+ unsubscribeMessage();
482
+ unsubscribeError();
483
+ await handle.terminate();
394
484
  }
395
485
  }
@@ -30,19 +30,8 @@ export interface TinyTitleProgressEvent {
30
30
  export type TinyTitleWorkerInbound =
31
31
  | { type: "ping"; id: string }
32
32
  | { type: "generate"; id: string; modelKey: TinyTitleLocalModelKey; message: string }
33
- | {
34
- type: "complete";
35
- id: string;
36
- modelKey: TinyLocalModelKey;
37
- prompt: string;
38
- maxTokens?: number;
39
- /** Optional assistant-turn prefix appended after the generation prompt to pin output format. */
40
- prefill?: string;
41
- /** Optional literal stop string; generation halts once it appears in the decoded tail. */
42
- stop?: string;
43
- }
44
- | { type: "download"; id: string; modelKey: TinyLocalModelKey }
45
- | { type: "close" };
33
+ | { type: "complete"; id: string; modelKey: TinyLocalModelKey; prompt: string; maxTokens?: number }
34
+ | { type: "download"; id: string; modelKey: TinyLocalModelKey };
46
35
 
47
36
  export type TinyTitleWorkerOutbound =
48
37
  | { type: "pong"; id: string }
@@ -51,11 +40,17 @@ export type TinyTitleWorkerOutbound =
51
40
  | { type: "downloaded"; id: string }
52
41
  | { type: "error"; id: string; error: string }
53
42
  | { type: "progress"; id: string; event: TinyTitleProgressEvent }
54
- | { type: "log"; level: "debug" | "warn" | "error"; msg: string; meta?: Record<string, unknown> }
55
- | { type: "closed" };
43
+ | { type: "log"; level: "debug" | "warn" | "error"; msg: string; meta?: Record<string, unknown> };
56
44
 
45
+ /**
46
+ * Wire transport between the parent (`TinyTitleClient`) and the tiny-model
47
+ * subprocess. The parent owns the subprocess lifecycle (graceful work, hard
48
+ * kill on shutdown); the protocol therefore carries no explicit close
49
+ * handshake — once the parent decides to terminate, it signals the OS to
50
+ * reap the child so `onnxruntime-node`'s NAPI finalizer never runs in any
51
+ * shared address space. See `title-client.ts` for the spawn/kill glue.
52
+ */
57
53
  export interface TinyTitleTransport {
58
54
  send(message: TinyTitleWorkerOutbound): void;
59
55
  onMessage(handler: (message: TinyTitleWorkerInbound) => void): () => void;
60
- close(): void;
61
56
  }
@@ -1,7 +1,6 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import { createRequire } from "node:module";
3
3
  import * as path from "node:path";
4
- import { parentPort } from "node:worker_threads";
5
4
  import type {
6
5
  ProgressInfo,
7
6
  TextGenerationPipeline,
@@ -20,12 +19,7 @@ import {
20
19
  type TinyTitleLocalModelSpec,
21
20
  } from "./models";
22
21
  import { formatTitleUserMessage, normalizeGeneratedTitle } from "./text";
23
- import type {
24
- TinyTitleProgressEvent,
25
- TinyTitleTransport,
26
- TinyTitleWorkerInbound,
27
- TinyTitleWorkerOutbound,
28
- } from "./title-protocol";
22
+ import type { TinyTitleProgressEvent, TinyTitleTransport, TinyTitleWorkerInbound } from "./title-protocol";
29
23
 
30
24
  const TITLE_PREFILL = "<title>";
31
25
  const TITLE_CLOSE = "</title>";
@@ -461,15 +455,14 @@ async function generateTitle(
461
455
  return extractTinyTitle(output[0]?.generated_text ?? "");
462
456
  }
463
457
 
464
- function buildCompletionPrompt(generator: TextGenerationPipeline, promptText: string, prefill?: string): string {
458
+ function buildCompletionPrompt(generator: TextGenerationPipeline, promptText: string): string {
465
459
  const chat = [{ role: "user", content: promptText }];
466
460
  const chatTemplateOptions = {
467
461
  add_generation_prompt: true,
468
462
  tokenize: false,
469
463
  enable_thinking: false,
470
464
  };
471
- const base = generator.tokenizer.apply_chat_template(chat, chatTemplateOptions) as string;
472
- return prefill ? `${base}${prefill}` : base;
465
+ return `${generator.tokenizer.apply_chat_template(chat, chatTemplateOptions)}`;
473
466
  }
474
467
 
475
468
  /**
@@ -484,37 +477,18 @@ async function generateCompletion(
484
477
  modelKey: TinyLocalModelKey,
485
478
  promptText: string,
486
479
  maxTokens: number | undefined,
487
- prefill?: string,
488
- stop?: string,
489
480
  ): Promise<string | null> {
490
481
  const generator = await loadPipeline(modelKey, transport, requestId);
491
- const text = buildCompletionPrompt(generator, promptText, prefill);
482
+ const text = buildCompletionPrompt(generator, promptText);
492
483
  const requested = maxTokens ?? MEMORY_COMPLETION_MAX_NEW_TOKENS;
493
484
  const maxNewTokens = Math.min(Math.max(1, requested), MEMORY_COMPLETION_MAX_NEW_TOKENS);
494
- const transformers = stop ? await loadTransformers(transport, requestId, modelKey) : undefined;
495
485
  const output = (await generator(text, {
496
486
  max_new_tokens: maxNewTokens,
497
487
  do_sample: false,
498
488
  return_full_text: false,
499
- ...(transformers && stop
500
- ? { stopping_criteria: createStopOnTextCriteria(transformers, generator.tokenizer, stop) }
501
- : {}),
502
489
  })) as TextGenerationStringOutput;
503
- const generated = output[0]?.generated_text ?? "";
504
- // Re-attach the forced prefix so the caller's parser sees the full assistant turn,
505
- // including the opening tag it pinned via `prefill`.
506
- const full = `${prefill ?? ""}${generated}`.trim();
507
- return full === "" ? null : full;
508
- }
509
-
510
- function releasePipelines(): void {
511
- // Intentionally NOT calling `pipeline.dispose()`. transformers.js disposes the
512
- // underlying onnxruntime InferenceSession, freeing native memory that Bun's
513
- // worker/NAPI teardown then frees a second time — a double-free that aborts the
514
- // process on quit ("malloc: pointer being freed was not allocated" /
515
- // "NAPI FATAL ERROR"). The worker is torn down immediately after `close`, so the
516
- // OS reclaims the model memory regardless; skipping dispose avoids the crash.
517
- pipelines.clear();
490
+ const generated = (output[0]?.generated_text ?? "").trim();
491
+ return generated === "" ? null : generated;
518
492
  }
519
493
 
520
494
  function enqueueRequest(
@@ -548,8 +522,6 @@ async function handleQueuedRequest(
548
522
  request.modelKey,
549
523
  request.prompt,
550
524
  request.maxTokens,
551
- request.prefill,
552
- request.stop,
553
525
  );
554
526
  transport.send({ type: "completion", id: request.id, text });
555
527
  return;
@@ -567,33 +539,6 @@ export function startTinyTitleWorker(transport: TinyTitleTransport): void {
567
539
  transport.send({ type: "pong", id: message.id });
568
540
  return;
569
541
  }
570
- if (message.type === "close") {
571
- releasePipelines();
572
- transport.send({ type: "closed" });
573
- transport.close();
574
- return;
575
- }
576
542
  enqueueRequest(transport, message);
577
543
  });
578
544
  }
579
-
580
- if (!parentPort) throw new Error("tiny-title-worker: missing parentPort");
581
-
582
- const port = parentPort;
583
- const transport: TinyTitleTransport = {
584
- send: (message: TinyTitleWorkerOutbound) => port.postMessage(message),
585
- onMessage: handler => {
586
- const wrap = (data: unknown): void => handler(data as TinyTitleWorkerInbound);
587
- port.on("message", wrap);
588
- return () => port.off("message", wrap);
589
- },
590
- close: () => {
591
- try {
592
- port.close();
593
- } catch {
594
- // Already closed.
595
- }
596
- },
597
- };
598
-
599
- startTinyTitleWorker(transport);