@oh-my-pi/pi-coding-agent 16.3.3 → 16.3.5

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 (47) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/cli.js +3405 -3382
  3. package/dist/types/hindsight/content.d.ts +4 -0
  4. package/dist/types/internal-urls/artifact-protocol.d.ts +8 -0
  5. package/dist/types/internal-urls/types.d.ts +10 -0
  6. package/dist/types/lsp/config.d.ts +4 -0
  7. package/dist/types/lsp/edits.d.ts +2 -0
  8. package/dist/types/mcp/oauth-discovery.d.ts +30 -0
  9. package/dist/types/modes/components/login-dialog.d.ts +10 -2
  10. package/dist/types/modes/components/transcript-container.d.ts +0 -1
  11. package/dist/types/modes/controllers/mcp-command-controller.d.ts +20 -3
  12. package/dist/types/modes/rpc/rpc-client.d.ts +7 -3
  13. package/dist/types/modes/rpc/rpc-types.d.ts +6 -0
  14. package/dist/types/subprocess/worker-runtime.d.ts +11 -0
  15. package/dist/types/tools/bash-skill-urls.d.ts +2 -2
  16. package/dist/types/utils/git.d.ts +16 -0
  17. package/package.json +12 -12
  18. package/src/cli/auth-broker-cli.ts +13 -2
  19. package/src/cli/tiny-models-cli.ts +12 -5
  20. package/src/cli/usage-cli.ts +34 -5
  21. package/src/hindsight/content.ts +31 -0
  22. package/src/internal-urls/artifact-protocol.ts +97 -53
  23. package/src/internal-urls/types.ts +10 -0
  24. package/src/lsp/config.ts +15 -0
  25. package/src/lsp/edits.ts +28 -7
  26. package/src/lsp/index.ts +46 -4
  27. package/src/mcp/oauth-discovery.ts +88 -18
  28. package/src/mnemopi/state.ts +26 -2
  29. package/src/modes/components/login-dialog.ts +16 -2
  30. package/src/modes/components/mcp-add-wizard.ts +9 -1
  31. package/src/modes/components/transcript-container.ts +0 -26
  32. package/src/modes/controllers/mcp-command-controller.ts +106 -29
  33. package/src/modes/controllers/selector-controller.ts +9 -1
  34. package/src/modes/rpc/rpc-client.ts +8 -4
  35. package/src/modes/rpc/rpc-mode.ts +1 -0
  36. package/src/modes/rpc/rpc-types.ts +13 -1
  37. package/src/modes/setup-wizard/scenes/sign-in.ts +18 -0
  38. package/src/prompts/tools/read.md +1 -1
  39. package/src/subprocess/worker-runtime.ts +219 -2
  40. package/src/task/worktree.ts +28 -6
  41. package/src/tiny/worker.ts +14 -4
  42. package/src/tools/bash-skill-urls.ts +3 -3
  43. package/src/tools/grep.ts +19 -2
  44. package/src/tools/path-utils.ts +4 -0
  45. package/src/tools/read.ts +198 -1
  46. package/src/utils/git.ts +20 -0
  47. package/src/utils/open.ts +51 -6
@@ -8,7 +8,13 @@ import { type Component, replaceTabs, Spacer, Text } from "@oh-my-pi/pi-tui";
8
8
  import { getMCPConfigPath, getProjectDir } from "@oh-my-pi/pi-utils";
9
9
  import type { SourceMeta } from "../../capability/types";
10
10
  import { expandEnvVarsDeep } from "../../discovery/helpers";
11
- import { analyzeAuthError, discoverOAuthEndpoints, loadAllMCPConfigs, MCPManager } from "../../mcp";
11
+ import {
12
+ analyzeAuthError,
13
+ discoverOAuthEndpoints,
14
+ fetchResourceMetadataScopes,
15
+ loadAllMCPConfigs,
16
+ MCPManager,
17
+ } from "../../mcp";
12
18
  import { connectToServer, disconnectServer, listTools } from "../../mcp/client";
13
19
  import {
14
20
  addMCPServer,
@@ -43,6 +49,7 @@ import {
43
49
  import type { MCPAuthConfig, MCPServerConfig, MCPServerConnection } from "../../mcp/types";
44
50
  import { shortenPath } from "../../tools/render-utils";
45
51
  import { urlHyperlinkAlways } from "../../tui";
52
+ import { copyToClipboard } from "../../utils/clipboard";
46
53
  import { openPath } from "../../utils/open";
47
54
  import { ChatBlock } from "../components/chat-block";
48
55
  import { MCPAddWizard } from "../components/mcp-add-wizard";
@@ -73,23 +80,77 @@ function raceAbortSignal<T>(promise: Promise<T>, signal: AbortSignal, createErro
73
80
  });
74
81
  }
75
82
 
76
- /** Renders the MCP OAuth fallback URL without hard-wrapping the copy target. */
83
+ /**
84
+ * Minimum column budget for URL wrapping. Below this the terminal is
85
+ * effectively unusable, but we still emit chunks so no character is silently
86
+ * dropped and the user can widen and reflow.
87
+ */
88
+ const MCP_AUTH_MIN_WRAP_WIDTH = 16;
89
+
90
+ /**
91
+ * Wrap `url` into rows that each fit inside `width`, prefixed by a shared
92
+ * single-column indent so nested composition doesn't touch column 0. When the
93
+ * label + URL fit on one line, returns a single row; otherwise puts the label
94
+ * on its own row and slices the URL into fixed-width chunks. URL chunks are
95
+ * plain code points — browsers strip whitespace when pasted into the address
96
+ * bar, so a multi-row selection copies back to the intact URL.
97
+ */
98
+ function wrapUrlRows(label: string, url: string, width: number): string[] {
99
+ const indent = " ";
100
+ const sanitized = replaceTabs(url);
101
+ const effective = Math.max(MCP_AUTH_MIN_WRAP_WIDTH, Math.trunc(width));
102
+ const inlineWidth = indent.length + label.length + 1 + sanitized.length;
103
+ if (inlineWidth <= effective) {
104
+ return [`${indent}${theme.fg("muted", `${label} ${sanitized}`)}`];
105
+ }
106
+ const chunkWidth = Math.max(1, effective - indent.length);
107
+ const rows: string[] = [`${indent}${theme.fg("muted", label)}`];
108
+ for (let i = 0; i < sanitized.length; i += chunkWidth) {
109
+ rows.push(`${indent}${theme.fg("muted", sanitized.slice(i, i + chunkWidth))}`);
110
+ }
111
+ return rows;
112
+ }
113
+
114
+ /**
115
+ * Renders the MCP OAuth fallback URL. Always shows the full authorization URL
116
+ * as the primary `Copy URL:` target — that works from any machine, including
117
+ * SSH/WSL/headless sessions where the OMP-hosted `/launch` loopback URL would
118
+ * resolve against the user's local browser and fail.
119
+ *
120
+ * The render is `width`-aware: on any viewport narrower than the composed row
121
+ * ({@link TUI#prepareLine} truncates anything wider with `Ellipsis.Omit`, no
122
+ * marker), the URL is hard-wrapped into width-fitted rows so the primary copy
123
+ * target can never silently lose trailing OAuth parameters — the failure mode
124
+ * that motivated #4418 in the first place. Browsers strip whitespace when a
125
+ * multi-row selection is pasted into the address bar, so the reassembled URL
126
+ * is byte-identical to what we rendered.
127
+ *
128
+ * When the flow's callback server hosts a short `launchUrl`, it is offered
129
+ * as an additional local shortcut for wide-terminal local users. The OSC 8
130
+ * hyperlink continues to carry the full URL for terminals that support it.
131
+ */
77
132
  export class MCPAuthorizationLinkPrompt implements Component {
78
- readonly #url: string;
133
+ readonly #fullUrl: string;
134
+ readonly #launchUrl: string | undefined;
79
135
 
80
- constructor(url: string) {
81
- this.#url = url;
136
+ constructor(url: string, launchUrl?: string) {
137
+ this.#fullUrl = url;
138
+ this.#launchUrl = launchUrl && launchUrl !== url ? launchUrl : undefined;
82
139
  }
83
140
 
84
141
  invalidate(): void {}
85
142
 
86
- render(_width: number): readonly string[] {
87
- const link = urlHyperlinkAlways(this.#url, "Click here to authorize");
88
- return [
143
+ render(width: number): readonly string[] {
144
+ const link = urlHyperlinkAlways(this.#fullUrl, "Click here to authorize");
145
+ const lines: string[] = [
89
146
  ` ${theme.fg("success", "Open authorization URL:")}`,
90
147
  ` ${theme.fg("accent", link)}`,
91
- ` ${theme.fg("muted", `Copy URL: ${replaceTabs(this.#url)}`)}`,
148
+ ...wrapUrlRows("Copy URL:", this.#fullUrl, width),
92
149
  ];
150
+ if (this.#launchUrl) {
151
+ lines.push(...wrapUrlRows("Local shortcut (this machine only):", this.#launchUrl, width));
152
+ }
153
+ return lines;
93
154
  }
94
155
  }
95
156
 
@@ -506,11 +567,18 @@ export class MCPCommandController {
506
567
  finalConfig.url,
507
568
  authResult.authServerUrl,
508
569
  authResult.resourceMetadataUrl,
570
+ { protectedScopes: authResult.scopes },
509
571
  );
510
572
  } catch {
511
573
  // Ignore discovery error and handle below.
512
574
  }
513
575
  }
576
+ if (oauth && !oauth.scopes && authResult.resourceMetadataUrl) {
577
+ // JSON-error-body path skips `discoverOAuthEndpoints`; fetch the
578
+ // advertised protected-resource metadata for the required scopes.
579
+ const scopes = await fetchResourceMetadataScopes(authResult.resourceMetadataUrl);
580
+ if (scopes) oauth = { ...oauth, scopes };
581
+ }
514
582
 
515
583
  if (!oauth) {
516
584
  this.ctx.showError(
@@ -689,7 +757,7 @@ export class MCPCommandController {
689
757
  stripSameOriginResource: opts?.stripSameOriginResource,
690
758
  },
691
759
  {
692
- onAuth: (info: { url: string; instructions?: string }) => {
760
+ onAuth: (info: { url: string; launchUrl?: string; instructions?: string }) => {
693
761
  // Show auth URL prominently in chat as one block
694
762
  const block = new TranscriptBlock();
695
763
  this.ctx.present(block);
@@ -707,24 +775,25 @@ export class MCPCommandController {
707
775
  block.addChild(new Text(theme.fg("muted", MCP_MANUAL_LOGIN_TIP), 1, 0));
708
776
  block.addChild(new Spacer(1));
709
777
  block.addChild(new Text(theme.fg("accent", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"), 1, 0));
710
- // Try to open browser automatically
711
- try {
712
- openPath(info.url);
713
-
714
- // Show confirmation that browser should open
715
- block.addChild(new Spacer(1));
716
- block.addChild(new Text(theme.fg("success", "→ Opening browser automatically..."), 1, 0));
717
- block.addChild(new Spacer(1));
718
- block.addChild(new Text(theme.fg("muted", "Alternative if browser did not open:"), 1, 0));
719
- block.addChild(new MCPAuthorizationLinkPrompt(info.url));
720
- this.ctx.ui.requestRender();
721
- } catch (_error) {
722
- // Show error if browser doesn't open
723
- block.addChild(new Spacer(1));
724
- block.addChild(new Text(theme.fg("warning", "→ Could not open browser automatically"), 1, 0));
725
- block.addChild(new MCPAuthorizationLinkPrompt(info.url));
726
- this.ctx.ui.requestRender();
727
- }
778
+ // `openPath` is best-effort it logs spawn failures but never
779
+ // throws, so we always render the copy-URL fallback beneath the
780
+ // "attempting to open browser" line and no earlier try/catch is
781
+ // worth keeping.
782
+ openPath(info.url);
783
+ // Stage the FULL authorization URL on the clipboard via OSC 52.
784
+ // The full URL works from any machine (unlike `launchUrl`, which
785
+ // only resolves against the OMP host), and OSC 52 is a
786
+ // wire-level protocol the terminal writes it to the user's
787
+ // LOCAL clipboard even when OMP is on a remote SSH box.
788
+ // Best-effort: falls back to the visible copy-URL rows below
789
+ // whether or not the terminal honors OSC 52.
790
+ void copyToClipboard(info.url).catch(() => {});
791
+ block.addChild(new Spacer(1));
792
+ block.addChild(new Text(theme.fg("success", "→ Attempting to open browser..."), 1, 0));
793
+ block.addChild(new Spacer(1));
794
+ block.addChild(new Text(theme.fg("muted", "Alternative if browser did not open:"), 1, 0));
795
+ block.addChild(new MCPAuthorizationLinkPrompt(info.url, info.launchUrl));
796
+ this.ctx.ui.requestRender();
728
797
  },
729
798
  onProgress: (message: string) => {
730
799
  this.ctx.present([new Spacer(1), new Text(theme.fg("muted", message), 1, 0)]);
@@ -1010,7 +1079,15 @@ export class MCPCommandController {
1010
1079
  let oauth = authResult.authType === "oauth" ? (authResult.oauth ?? null) : null;
1011
1080
 
1012
1081
  if (!oauth && (config.type === "http" || config.type === "sse") && config.url) {
1013
- oauth = await discoverOAuthEndpoints(config.url, authResult.authServerUrl, authResult.resourceMetadataUrl);
1082
+ oauth = await discoverOAuthEndpoints(config.url, authResult.authServerUrl, authResult.resourceMetadataUrl, {
1083
+ protectedScopes: authResult.scopes,
1084
+ });
1085
+ }
1086
+ if (oauth && !oauth.scopes && authResult.resourceMetadataUrl) {
1087
+ // JSON-error-body path skips `discoverOAuthEndpoints`; fetch the
1088
+ // advertised protected-resource metadata for the required scopes.
1089
+ const scopes = await fetchResourceMetadataScopes(authResult.resourceMetadataUrl);
1090
+ if (scopes) oauth = { ...oauth, scopes };
1014
1091
  }
1015
1092
 
1016
1093
  if (!oauth) {
@@ -1117,11 +1117,19 @@ export class SelectorController {
1117
1117
  const useManualInput = PASTE_CODE_LOGIN_PROVIDERS.has(providerId);
1118
1118
  try {
1119
1119
  await this.ctx.session.modelRegistry.authStorage.login(providerId as OAuthProvider, {
1120
- onAuth: (info: { url: string; instructions?: string }) => {
1120
+ onAuth: (info: { url: string; launchUrl?: string; instructions?: string }) => {
1121
1121
  const block = new TranscriptBlock();
1122
+ // Full URL first: works from any machine, including SSH boxes
1123
+ // where the OMP-hosted `launchUrl` would resolve against the
1124
+ // user's local browser and fail.
1122
1125
  block.addChild(new Text(theme.fg("dim", info.url), 1, 0));
1123
1126
  const hyperlink = `\x1b]8;;${info.url}\x07Click here to login\x1b]8;;\x07`;
1124
1127
  block.addChild(new Text(theme.fg("accent", hyperlink), 1, 0));
1128
+ if (info.launchUrl && info.launchUrl !== info.url) {
1129
+ block.addChild(
1130
+ new Text(theme.fg("dim", `Local shortcut (this machine only): ${info.launchUrl}`), 1, 0),
1131
+ );
1132
+ }
1125
1133
  if (info.instructions) {
1126
1134
  block.addChild(new Spacer(1));
1127
1135
  block.addChild(new Text(theme.fg("warning", info.instructions), 1, 0));
@@ -724,17 +724,21 @@ export class RpcClient {
724
724
  * The server will emit an `open_url` extension_ui_request for the auth URL.
725
725
  * Resolves when login completes or rejects on failure.
726
726
  *
727
- * @param onOpenUrl Called when the server emits the auth URL. The host must open
728
- * it in a browser for the callback-server OAuth flow to complete.
727
+ * @param onOpenUrl Called when the server emits the auth URL. The host must
728
+ * open `url` in a browser for the callback-server OAuth flow to complete.
729
+ * When the flow's callback server hosts a `/launch` redirect, `launchUrl`
730
+ * is a short loopback URL that 302s to `url` — hosts SHOULD surface it as
731
+ * the truncation-safe copy target so terminal viewport clipping cannot
732
+ * corrupt trailing OAuth query parameters (e.g. `code_challenge_method=S256`).
729
733
  */
730
734
  async login(
731
735
  providerId: string,
732
- options?: { onOpenUrl?: (url: string, instructions?: string) => void },
736
+ options?: { onOpenUrl?: (url: string, instructions?: string, launchUrl?: string) => void },
733
737
  ): Promise<{ providerId: string }> {
734
738
  const { onOpenUrl } = options ?? {};
735
739
  const listener = onOpenUrl
736
740
  ? (req: RpcExtensionUIRequest) => {
737
- if (req.method === "open_url") onOpenUrl(req.url, req.instructions);
741
+ if (req.method === "open_url") onOpenUrl(req.url, req.instructions, req.launchUrl);
738
742
  }
739
743
  : undefined;
740
744
  if (listener) this.#extensionUiListeners.add(listener);
@@ -1213,6 +1213,7 @@ export async function runRpcMode(
1213
1213
  id: Snowflake.next() as string,
1214
1214
  method: "open_url",
1215
1215
  url: info.url,
1216
+ launchUrl: info.launchUrl,
1216
1217
  instructions: info.instructions,
1217
1218
  } as RpcExtensionUIRequest);
1218
1219
  },
@@ -370,7 +370,19 @@ export type RpcExtensionUIRequest =
370
370
  }
371
371
  | { type: "extension_ui_request"; id: string; method: "setTitle"; title: string }
372
372
  | { type: "extension_ui_request"; id: string; method: "set_editor_text"; text: string }
373
- | { type: "extension_ui_request"; id: string; method: "open_url"; url: string; instructions?: string };
373
+ | {
374
+ type: "extension_ui_request";
375
+ id: string;
376
+ method: "open_url";
377
+ url: string;
378
+ /**
379
+ * Short loopback URL that 302-redirects to {@link url}. When present,
380
+ * hosts SHOULD surface it as the copy target so terminal viewport
381
+ * truncation cannot corrupt OAuth query parameters on the full URL.
382
+ */
383
+ launchUrl?: string;
384
+ instructions?: string;
385
+ };
374
386
 
375
387
  // ============================================================================
376
388
  // Host Tool Frames (bidirectional)
@@ -80,6 +80,7 @@ export class SignInTab implements SetupTab {
80
80
  #selector: OAuthSelectorComponent;
81
81
  #statusLines: string[] = [];
82
82
  #authUrl: string | undefined;
83
+ #authLaunchUrl: string | undefined;
83
84
  #prompt: PromptState | undefined;
84
85
  #promptResolve: ((value: string) => void) | undefined;
85
86
  #loginAbort: AbortController | undefined;
@@ -146,6 +147,9 @@ export class SignInTab implements SetupTab {
146
147
  theme.fg("accent", `Browser login: ${loginUrlLink(this.#authUrl)} ${loginCopyHint()}`),
147
148
  ...urlLines.slice(0, 2),
148
149
  );
150
+ if (this.#authLaunchUrl) {
151
+ lines.push(theme.fg("dim", `Local shortcut (this machine only): ${this.#authLaunchUrl}`));
152
+ }
149
153
  }
150
154
  if (this.#prompt) {
151
155
  lines.push(theme.fg("warning", this.#prompt.message));
@@ -182,6 +186,7 @@ export class SignInTab implements SetupTab {
182
186
  this.#loggingInProvider = providerId;
183
187
  this.#statusLines = [theme.fg("dim", "Starting OAuth flow…")];
184
188
  this.#authUrl = undefined;
189
+ this.#authLaunchUrl = undefined;
185
190
  this.#loginAbort = new AbortController();
186
191
  this.host.restoreFocus();
187
192
  this.host.requestRender();
@@ -189,7 +194,17 @@ export class SignInTab implements SetupTab {
189
194
  await this.#authStorage.login(providerId as OAuthProvider, {
190
195
  signal: this.#loginAbort.signal,
191
196
  onAuth: info => {
197
+ // Store the full authorization URL as the primary copy/display
198
+ // target: it works from any machine, including SSH boxes where
199
+ // the OMP-hosted `launchUrl` would resolve against the user's
200
+ // local browser and fail. The wizard render uses
201
+ // `wrapTextWithAnsi`, so long URLs wrap across lines rather
202
+ // than getting truncated — the RFC 7636 §4.3 PKCE-downgrade
203
+ // bug that motivated `launchUrl` is unreachable through this
204
+ // surface. `launchUrl` is still surfaced as an optional local
205
+ // shortcut for wide-terminal local users.
192
206
  this.#authUrl = info.url;
207
+ this.#authLaunchUrl = info.launchUrl && info.launchUrl !== info.url ? info.launchUrl : undefined;
193
208
  this.#statusLines = [];
194
209
  if (info.instructions) {
195
210
  this.#statusLines.push(theme.fg("warning", info.instructions));
@@ -216,6 +231,7 @@ export class SignInTab implements SetupTab {
216
231
  theme.fg("dim", `Credentials saved to ${getAgentDbPath()}`),
217
232
  ];
218
233
  this.#authUrl = undefined;
234
+ this.#authLaunchUrl = undefined;
219
235
  this.#loggingInProvider = undefined;
220
236
  this.#loginAbort = undefined;
221
237
  this.#selector.stopValidation();
@@ -227,6 +243,7 @@ export class SignInTab implements SetupTab {
227
243
  if (this.#loginAbort?.signal.aborted) {
228
244
  this.#statusLines = [theme.fg("dim", "Login cancelled.")];
229
245
  this.#authUrl = undefined;
246
+ this.#authLaunchUrl = undefined;
230
247
  } else {
231
248
  const message = error instanceof Error ? error.message : String(error);
232
249
  this.#statusLines = [
@@ -234,6 +251,7 @@ export class SignInTab implements SetupTab {
234
251
  theme.fg("dim", "Choose another provider or press Esc to continue."),
235
252
  ];
236
253
  this.#authUrl = undefined;
254
+ this.#authLaunchUrl = undefined;
237
255
  }
238
256
  this.#loggingInProvider = undefined;
239
257
  this.#loginAbort = undefined;
@@ -67,7 +67,7 @@ For `.sqlite`, `.sqlite3`, `.db`, `.db3`:
67
67
 
68
68
  # Internal URIs
69
69
 
70
- All URI schemes take the same line selectors. `artifact://<id>` recovers full output a bash/eval/tool result spilled or truncated.
70
+ All URI schemes take the same line selectors. `artifact://<id>` recovers spilled output; large artifacts block unbounded `:raw`, so page with `artifact://<id>:N-M` / `artifact://<id>:raw:N-M` and use the reported artifact file path for search/copy workflows.
71
71
 
72
72
  `ssh://host/<absolute-path>` reads a remote text file (UTF-8, ≤1 MiB) or lists a directory one level deep, on a pre-configured SSH host or `~/.ssh/config` alias; `ssh://host/` lists the remote root and bare `ssh://` lists the configured hosts. Files are also writable via `write` and searchable via `search`; a directory only lists (`search` refuses a directory, `write` refuses to overwrite one). A literal `:`, `?`, or `#` in the remote path must be percent-encoded (`%3A`/`%3F`/`%23`) — a trailing `:sel` is read as a line selector, and `?`/`#` start a URL query/fragment. Requires a POSIX login shell (`sh`/`bash`/`zsh`); a Windows host or a non-POSIX shell (fish, csh/tcsh) is rejected — use the `ssh` tool there.
73
73
 
@@ -1,3 +1,4 @@
1
+ import * as fsp from "node:fs/promises";
1
2
  import { createRequire } from "node:module";
2
3
  import * as path from "node:path";
3
4
  import type { ProgressInfo } from "@huggingface/transformers";
@@ -27,6 +28,15 @@ import packageJson from "../../package.json" with { type: "json" };
27
28
 
28
29
  export const TRANSFORMERS_PACKAGE = "@huggingface/transformers";
29
30
  const COMPILED_TRANSFORMERS_VERSION = process.env.PI_TINY_TRANSFORMERS_VERSION;
31
+ const ONNX_RUNTIME_NODE_PACKAGE = "onnxruntime-node";
32
+ const ONNX_RUNTIME_CUDA_INSTALL = "cuda12";
33
+ const ONNX_RUNTIME_CUDA_PROVIDER_FILES = [
34
+ "libonnxruntime_providers_cuda.so",
35
+ "libonnxruntime_providers_shared.so",
36
+ "libonnxruntime_providers_tensorrt.so",
37
+ ] as const;
38
+ const LINUX_X64_ONNX_RUNTIME_CUDA_PROVIDER_DIR = path.join("bin", "napi-v6", "linux", "x64");
39
+
30
40
  const sourceRequire = createRequire(import.meta.url);
31
41
 
32
42
  // ── Error serialization ─────────────────────────────────────────────
@@ -162,6 +172,87 @@ export async function installSharpStubResolver(runtimeDir: string): Promise<stri
162
172
  return nodeModules;
163
173
  }
164
174
 
175
+ function shouldInstallOnnxRuntimeCudaProviders(device: string | undefined): boolean {
176
+ const normalized = device?.trim().toLowerCase();
177
+ return (
178
+ process.platform === "linux" &&
179
+ process.arch === "x64" &&
180
+ (normalized === "cuda" || normalized === "gpu" || normalized === "auto")
181
+ );
182
+ }
183
+
184
+ async function missingOnnxRuntimeCudaProviderFiles(binDir: string): Promise<string[]> {
185
+ const missing: string[] = [];
186
+ for (const file of ONNX_RUNTIME_CUDA_PROVIDER_FILES) {
187
+ try {
188
+ await fsp.access(path.join(binDir, file));
189
+ } catch {
190
+ missing.push(file);
191
+ }
192
+ }
193
+ return missing;
194
+ }
195
+
196
+ async function readPipe(stream: ReadableStream<Uint8Array> | null): Promise<string> {
197
+ if (!stream) return "";
198
+ return new Response(stream).text();
199
+ }
200
+
201
+ async function installOnnxRuntimeCudaProviders(packageDir: string, runtimeDir: string, binDir: string): Promise<void> {
202
+ const script = path.join(packageDir, "script", "install.js");
203
+ try {
204
+ await fsp.access(script);
205
+ } catch {
206
+ throw new Error(
207
+ `ONNX Runtime CUDA provider binaries are missing from ${binDir}, and ${script} is unavailable. Remove the tiny-model side runtime cache at ${runtimeDir} and retry.`,
208
+ );
209
+ }
210
+
211
+ const proc = Bun.spawn([process.execPath, script], {
212
+ cwd: runtimeDir,
213
+ env: { ...Bun.env, BUN_BE_BUN: "1", ONNXRUNTIME_NODE_INSTALL: ONNX_RUNTIME_CUDA_INSTALL },
214
+ stdout: "pipe",
215
+ stderr: "pipe",
216
+ });
217
+ const [stdout, stderr, exitCode] = await Promise.all([
218
+ readPipe(proc.stdout as ReadableStream<Uint8Array> | null),
219
+ readPipe(proc.stderr as ReadableStream<Uint8Array> | null),
220
+ proc.exited,
221
+ ]);
222
+ if (exitCode !== 0) {
223
+ const output = `${stdout}\n${stderr}`.trim();
224
+ throw new Error(
225
+ `Failed to install ONNX Runtime CUDA provider binaries into ${binDir} with ${process.execPath} ${script} (exit ${exitCode}). Remove the tiny-model side runtime cache at ${runtimeDir} and retry with network access. ${output}`,
226
+ );
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Repairs the compiled Transformers side runtime when CUDA was requested and
232
+ * Bun skipped `onnxruntime-node`'s NuGet sidecar install.
233
+ */
234
+ export async function ensureOnnxRuntimeCudaProviders(
235
+ runtimeDir: string,
236
+ device = process.env.PI_TINY_DEVICE,
237
+ ): Promise<void> {
238
+ if (!shouldInstallOnnxRuntimeCudaProviders(device)) return;
239
+ const nodeModules = path.join(runtimeDir, "node_modules");
240
+ const manifest = resolveRuntimeModule(nodeModules, `${ONNX_RUNTIME_NODE_PACKAGE}/package.json`);
241
+ if (!manifest)
242
+ throw new Error(`Unable to resolve ${ONNX_RUNTIME_NODE_PACKAGE} in compiled runtime at ${nodeModules}`);
243
+ const packageDir = path.dirname(manifest);
244
+ const binDir = path.join(packageDir, LINUX_X64_ONNX_RUNTIME_CUDA_PROVIDER_DIR);
245
+ const missing = await missingOnnxRuntimeCudaProviderFiles(binDir);
246
+ if (missing.length === 0) return;
247
+
248
+ await installOnnxRuntimeCudaProviders(packageDir, runtimeDir, binDir);
249
+ const stillMissing = await missingOnnxRuntimeCudaProviderFiles(binDir);
250
+ if (stillMissing.length === 0) return;
251
+ throw new Error(
252
+ `ONNX Runtime CUDA provider install completed but ${stillMissing.join(", ")} are still missing from ${binDir}. Remove the tiny-model side runtime cache at ${runtimeDir} and retry.`,
253
+ );
254
+ }
255
+
165
256
  /**
166
257
  * Prepare a freshly-installed compiled runtime for loading and return the
167
258
  * absolute entrypoint of `packageName` to `require`.
@@ -211,6 +302,114 @@ interface ConfigurableTransformers {
211
302
  LogLevel: { ERROR: unknown };
212
303
  }
213
304
 
305
+ export interface TransformersRuntimeMetadata {
306
+ __ompRuntimeNodeModules?: string;
307
+ __ompTransformersEntry?: string;
308
+ __ompCudaRepairError?: string;
309
+ }
310
+
311
+ function attachTransformersRuntimeMetadata<T extends ConfigurableTransformers>(
312
+ transformers: T,
313
+ metadata: TransformersRuntimeMetadata,
314
+ ): T {
315
+ const runtime = transformers as T & TransformersRuntimeMetadata;
316
+ runtime.__ompRuntimeNodeModules = metadata.__ompRuntimeNodeModules;
317
+ runtime.__ompTransformersEntry = metadata.__ompTransformersEntry;
318
+ runtime.__ompCudaRepairError = metadata.__ompCudaRepairError;
319
+ return runtime;
320
+ }
321
+
322
+ const TRANSITIVE_CUDA_LIBRARY_RE =
323
+ /\b(lib(?:cu|nv)[A-Za-z0-9_.+-]*\.so(?:\.[0-9]+)*)\b[^:\n]*:\s*cannot open shared object file/iu;
324
+ const CUDA_DEVICE_UNAVAILABLE_RE = /\bCUDA failure 100\b|no CUDA-capable device is detected|cudaSetDevice|GPU=-1/iu;
325
+
326
+ function cudaDeviceUnavailable(error: unknown): boolean {
327
+ return CUDA_DEVICE_UNAVAILABLE_RE.test(errorText(error));
328
+ }
329
+
330
+ function missingCudaLibrary(error: unknown): string | undefined {
331
+ return TRANSITIVE_CUDA_LIBRARY_RE.exec(errorText(error))?.[1];
332
+ }
333
+
334
+ function cudaFailureCause(
335
+ metadata: TransformersRuntimeMetadata,
336
+ error: unknown,
337
+ missingFiles: readonly string[],
338
+ ): string {
339
+ if (metadata.__ompCudaRepairError) {
340
+ return `ONNX Runtime CUDA provider install failed: ${metadata.__ompCudaRepairError}`;
341
+ }
342
+ if (missingFiles.length > 0) return `missing ONNX Runtime CUDA provider file(s): ${missingFiles.join(", ")}`;
343
+ const missingLibrary = missingCudaLibrary(error);
344
+ if (missingLibrary) return `${missingLibrary}: cannot open shared object file`;
345
+ if (cudaDeviceUnavailable(error)) {
346
+ return "CUDA provider files are present; CUDA runtime reports no CUDA-capable device";
347
+ }
348
+ return "CUDA provider files are present; inspect the original ONNX Runtime CUDA error";
349
+ }
350
+
351
+ function cudaFailureHint(
352
+ metadata: TransformersRuntimeMetadata,
353
+ error: unknown,
354
+ missingFiles: readonly string[],
355
+ ): string {
356
+ if (metadata.__ompCudaRepairError) {
357
+ return "restore network access to nuget.org (or pre-populate the tiny side runtime) and rerun; CPU inference remained available";
358
+ }
359
+ if (missingFiles.length > 0) return "reinstall the tiny side runtime with ONNX Runtime postinstall enabled";
360
+ if (missingCudaLibrary(error)) {
361
+ return "install the matching CUDA/cuDNN shared libraries and expose them on the dynamic loader path";
362
+ }
363
+ if (cudaDeviceUnavailable(error)) {
364
+ return "make the NVIDIA GPU visible to this process/session, or use providers.tinyModelDevice=default/cpu";
365
+ }
366
+ return "check the host CUDA driver, device visibility, and ONNX Runtime CUDA compatibility";
367
+ }
368
+
369
+ function resolveOnnxRuntimePackageDir(metadata: TransformersRuntimeMetadata): string | null {
370
+ const entry = metadata.__ompTransformersEntry;
371
+ if (entry) {
372
+ try {
373
+ return path.dirname(createRequire(entry).resolve(`${ONNX_RUNTIME_NODE_PACKAGE}/package.json`));
374
+ } catch {
375
+ // Fall through to the side-runtime resolver below.
376
+ }
377
+ }
378
+ const nodeModules = metadata.__ompRuntimeNodeModules;
379
+ if (!nodeModules) return null;
380
+ const manifest = resolveRuntimeModule(nodeModules, `${ONNX_RUNTIME_NODE_PACKAGE}/package.json`);
381
+ return manifest ? path.dirname(manifest) : null;
382
+ }
383
+
384
+ export async function formatOnnxRuntimeCudaDiagnostics(
385
+ metadata: TransformersRuntimeMetadata,
386
+ requestedDevice: string,
387
+ error: unknown,
388
+ ): Promise<string | null> {
389
+ const device = requestedDevice.trim().toLowerCase();
390
+ if (device !== "cuda" && device !== "gpu" && device !== "auto") return null;
391
+ if (process.platform !== "linux" || process.arch !== "x64") return null;
392
+ const packageDir = resolveOnnxRuntimePackageDir(metadata);
393
+ if (!packageDir) {
394
+ return [
395
+ "ONNX Runtime CUDA diagnostics:",
396
+ ` PI_TINY_DEVICE=${requestedDevice} requested CUDAExecutionProvider`,
397
+ " cause: unable to resolve onnxruntime-node in the tiny-model runtime",
398
+ ].join("\n");
399
+ }
400
+ const binDir = path.join(packageDir, LINUX_X64_ONNX_RUNTIME_CUDA_PROVIDER_DIR);
401
+ const missingFiles = await missingOnnxRuntimeCudaProviderFiles(binDir);
402
+ const sideRuntime = metadata.__ompRuntimeNodeModules;
403
+ const lines = [
404
+ "ONNX Runtime CUDA diagnostics:",
405
+ ` PI_TINY_DEVICE=${requestedDevice} requested CUDAExecutionProvider`,
406
+ sideRuntime ? ` side runtime: ${sideRuntime}` : ` onnxruntime-node: ${packageDir}`,
407
+ ` cause: ${cudaFailureCause(metadata, error, missingFiles)}`,
408
+ ];
409
+ lines.push(` hint: ${cudaFailureHint(metadata, error, missingFiles)}`);
410
+ return lines.join("\n");
411
+ }
412
+
214
413
  function configureTransformers<T extends ConfigurableTransformers>(transformers: T): T {
215
414
  transformers.env.cacheDir = getTinyModelsCacheDir();
216
415
  transformers.env.allowLocalModels = false;
@@ -251,7 +450,12 @@ export function loadTransformersRuntime<T extends ConfigurableTransformers, K>(
251
450
  runtimeDir: () => string,
252
451
  ): Promise<T> {
253
452
  return holder.load(async () => {
254
- if (!isCompiledBinary()) return configureTransformers(sourceRequire(TRANSFORMERS_PACKAGE) as T);
453
+ if (!isCompiledBinary()) {
454
+ const entry = sourceRequire.resolve(TRANSFORMERS_PACKAGE);
455
+ return attachTransformersRuntimeMetadata(configureTransformers(sourceRequire(entry) as T), {
456
+ __ompTransformersEntry: entry,
457
+ });
458
+ }
255
459
  const installedDir = await ensureRuntimeInstalled({
256
460
  runtimeDir: runtimeDir(),
257
461
  install: {
@@ -270,8 +474,21 @@ export function loadTransformersRuntime<T extends ConfigurableTransformers, K>(
270
474
  },
271
475
  }),
272
476
  });
477
+ let cudaRepairError: string | undefined;
478
+ try {
479
+ await ensureOnnxRuntimeCudaProviders(installedDir);
480
+ } catch (repairError) {
481
+ // Deferred failure: keep loading Transformers so `loadPipelineWithDeviceFallback`
482
+ // still gets its CUDA→CPU retry. The error is surfaced through the CUDA
483
+ // diagnostics attached to the runtime metadata.
484
+ cudaRepairError = errorMessage(repairError);
485
+ }
273
486
  const entry = await prepareCompiledRuntime(installedDir, TRANSFORMERS_PACKAGE);
274
487
  const require_ = createRequire(entry);
275
- return configureTransformers(require_(entry) as T);
488
+ return attachTransformersRuntimeMetadata(configureTransformers(require_(entry) as T), {
489
+ __ompRuntimeNodeModules: path.join(installedDir, "node_modules"),
490
+ __ompTransformersEntry: entry,
491
+ __ompCudaRepairError: cudaRepairError,
492
+ });
276
493
  });
277
494
  }
@@ -855,18 +855,40 @@ export async function mergeTaskBranches(
855
855
  try {
856
856
  const target = baseSha ? `${baseSha}..${branchName}` : branchName;
857
857
  await git.cherryPick(repoRoot, target);
858
- } catch (err) {
858
+ } catch (initialErr) {
859
+ // Empty cherry-picks are not conflicts: a commit whose net
860
+ // effect is already on HEAD (redundant change, or 3-way
861
+ // merge auto-resolved to HEAD) leaves the sequencer stopped
862
+ // with a "The previous cherry-pick is now empty" message.
863
+ // Advance past every consecutive empty with `--skip` so the
864
+ // remaining non-redundant commits in the range still land.
865
+ // A genuine conflict (unmerged files, no "now empty"
866
+ // message) falls through to the abort path below.
867
+ let cursor: unknown = initialErr;
868
+ while (git.cherryPick.isEmptyError(cursor)) {
869
+ try {
870
+ await git.cherryPick.skip(repoRoot);
871
+ cursor = undefined;
872
+ break;
873
+ } catch (skipErr) {
874
+ cursor = skipErr;
875
+ }
876
+ }
877
+ if (cursor === undefined) {
878
+ merged.push(branchName);
879
+ continue;
880
+ }
859
881
  try {
860
882
  await git.cherryPick.abort(repoRoot);
861
883
  } catch {
862
884
  /* no state to abort */
863
885
  }
864
886
  const stderr =
865
- err instanceof git.GitCommandError
866
- ? err.result.stderr.trim()
867
- : err instanceof Error
868
- ? err.message
869
- : String(err);
887
+ cursor instanceof git.GitCommandError
888
+ ? cursor.result.stderr.trim()
889
+ : cursor instanceof Error
890
+ ? cursor.message
891
+ : String(cursor);
870
892
  failed.push(branchName);
871
893
  conflictResult = {
872
894
  merged,