@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
@@ -10,12 +10,14 @@ import tinyTitleSystemPrompt from "../prompts/system/tiny-title-system.md" with
10
10
  import {
11
11
  errorMessage,
12
12
  errorText,
13
+ formatOnnxRuntimeCudaDiagnostics,
13
14
  getTransformersVersionSpec,
14
15
  loadTransformersRuntime,
15
16
  MemoizedRuntime,
16
17
  replayCachedReady,
17
18
  sendLog,
18
19
  sendProgress,
20
+ type TransformersRuntimeMetadata,
19
21
  } from "../subprocess/worker-runtime";
20
22
  import { resolveTinyModelDevicePreference, type TinyModelDevice, tinyModelDeviceLoadOrder } from "./device";
21
23
  import { resolveTinyModelDtypeOverride, type TinyModelDtype } from "./dtype";
@@ -39,7 +41,7 @@ const TINY_TITLE_SYSTEM_PROMPT = prompt.render(tinyTitleSystemPrompt);
39
41
  const tinyModelDevicePreference = resolveTinyModelDevicePreference();
40
42
  const tinyModelDtypeOverride = resolveTinyModelDtypeOverride();
41
43
 
42
- interface TransformersRuntime {
44
+ interface TransformersRuntime extends TransformersRuntimeMetadata {
43
45
  env: {
44
46
  cacheDir?: string;
45
47
  allowLocalModels?: boolean;
@@ -136,6 +138,7 @@ async function loadPipelineWithDeviceFallback(
136
138
  device: devices[0],
137
139
  });
138
140
  }
141
+ let cudaDiagnostics: string | null = null;
139
142
  for (let i = 0; i < devices.length; i += 1) {
140
143
  const device = devices[i]!;
141
144
  try {
@@ -144,15 +147,22 @@ async function loadPipelineWithDeviceFallback(
144
147
  device,
145
148
  };
146
149
  } catch (error) {
147
- if (i === devices.length - 1) throw error;
150
+ const deviceDiagnostics = await formatOnnxRuntimeCudaDiagnostics(transformers, device, error);
151
+ if (deviceDiagnostics) cudaDiagnostics = deviceDiagnostics;
152
+ if (i === devices.length - 1) {
153
+ if (cudaDiagnostics) throw new Error(`${errorText(error)}\n${cudaDiagnostics}`);
154
+ throw error;
155
+ }
148
156
  const fallbackDevice = devices[i + 1]!;
149
- sendLog(transport, "warn", "tiny-model: accelerated device failed; falling back", {
157
+ const meta: Record<string, unknown> = {
150
158
  modelKey,
151
159
  repo: spec.repo,
152
160
  device,
153
161
  fallbackDevice,
154
162
  error: errorMessage(error),
155
- });
163
+ };
164
+ if (deviceDiagnostics) meta.cudaDiagnostics = deviceDiagnostics;
165
+ sendLog(transport, "warn", "tiny-model: accelerated device failed; falling back", meta);
156
166
  }
157
167
  }
158
168
  throw new Error("No tiny model devices configured");
@@ -3,7 +3,7 @@ import * as path from "node:path";
3
3
  import type { Skill } from "../extensibility/skills";
4
4
  import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
5
5
  import { validateRelativePath } from "../internal-urls/skill-protocol";
6
- import type { InternalResource } from "../internal-urls/types";
6
+ import type { InternalResource, ResolveContext } from "../internal-urls/types";
7
7
  import { normalizeLocalScheme } from "./path-utils";
8
8
  import { ToolError } from "./tool-errors";
9
9
 
@@ -19,7 +19,7 @@ type SupportedInternalScheme = (typeof SUPPORTED_INTERNAL_SCHEMES)[number];
19
19
 
20
20
  interface InternalUrlResolver {
21
21
  canHandle(input: string): boolean;
22
- resolve(input: string): Promise<InternalResource>;
22
+ resolve(input: string, context?: ResolveContext): Promise<InternalResource>;
23
23
  }
24
24
 
25
25
  export interface InternalUrlExpansionOptions {
@@ -184,7 +184,7 @@ async function resolveInternalUrlToPath(
184
184
 
185
185
  let resource: InternalResource;
186
186
  try {
187
- resource = await internalRouter.resolve(url);
187
+ resource = await internalRouter.resolve(url, { pathOnly: true });
188
188
  } catch (error) {
189
189
  const message = error instanceof Error ? error.message : String(error);
190
190
  throw new ToolError(`Failed to resolve ${scheme}:// URL in bash command: ${url}\n${message}`);
package/src/tools/grep.ts CHANGED
@@ -750,6 +750,11 @@ async function resolveInternalSearchInputs(opts: {
750
750
  localProtocolOptions: opts.localProtocolOptions,
751
751
  skills: opts.skills,
752
752
  skipDirectoryListing: true,
753
+ // Try path-only first so large artifacts (and any other handler that
754
+ // separates path from content) resolve without materializing bytes.
755
+ // Handlers that ignore the flag still return content, and virtual
756
+ // resources without a sourcePath fall through to a second resolve.
757
+ pathOnly: true,
753
758
  };
754
759
 
755
760
  for (let idx = 0; idx < paths.length; idx++) {
@@ -764,7 +769,7 @@ async function resolveInternalSearchInputs(opts: {
764
769
  if (hasGlobPathChars(globTarget)) {
765
770
  throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
766
771
  }
767
- const resource = await internalRouter.resolve(rawPath, context);
772
+ let resource = await internalRouter.resolve(rawPath, context);
768
773
  // A directory listing with no backing local path (e.g. a remote ssh:// dir)
769
774
  // has no real contents to grep — searching its listing text would be
770
775
  // misleading. Local/skill/vault dir resources set `sourcePath` and skip this.
@@ -781,8 +786,20 @@ async function resolveInternalSearchInputs(opts: {
781
786
  continue;
782
787
  }
783
788
 
789
+ // No sourcePath: this handler needs its content materialized so the
790
+ // virtual expansion can search it. Re-resolve without pathOnly.
791
+ if (context.pathOnly) {
792
+ resource = await internalRouter.resolve(rawPath, { ...context, pathOnly: false });
793
+ }
794
+
784
795
  const ranges = opts.pathSpecs[idx]?.ranges;
785
- const expanded = await expandVirtualInternalResource(rawPath, resource, internalRouter, context, ranges);
796
+ const expanded = await expandVirtualInternalResource(
797
+ rawPath,
798
+ resource,
799
+ internalRouter,
800
+ { ...context, pathOnly: false },
801
+ ranges,
802
+ );
786
803
  virtualInputIndexes.add(idx);
787
804
  for (const virtual of expanded) {
788
805
  virtualResources.push(virtual);
@@ -1201,6 +1201,10 @@ export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<To
1201
1201
  signal: opts.signal,
1202
1202
  localProtocolOptions: opts.localProtocolOptions,
1203
1203
  skills: opts.skills,
1204
+ // Tool-scope resolution only needs `sourcePath`; skip content
1205
+ // materialization so large artifacts (or any handler that separates
1206
+ // path from content) stay searchable without OOM risk.
1207
+ pathOnly: true,
1204
1208
  });
1205
1209
  if (!resource.sourcePath) {
1206
1210
  throw new ToolError(`Cannot ${internalUrlAction} internal URL without a backing file: ${rawPath}`);
package/src/tools/read.ts CHANGED
@@ -37,6 +37,7 @@ import { normalizeToLF } from "../edit/normalize";
37
37
  import { isNotebookPath, readEditableNotebookText } from "../edit/notebook";
38
38
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
39
39
  import { InternalUrlRouter, resolveLocalUrlToFile } from "../internal-urls";
40
+ import { type ResolvedArtifactFile, resolveArtifactFile } from "../internal-urls/artifact-protocol";
40
41
  import { parseInternalUrl } from "../internal-urls/parse";
41
42
  import type { InternalUrl } from "../internal-urls/types";
42
43
  import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
@@ -151,6 +152,7 @@ const CONVERTIBLE_EXTENSIONS = new Set([".pdf", ".doc", ".docx", ".ppt", ".pptx"
151
152
 
152
153
  const MAX_SUMMARY_BYTES = 2 * 1024 * 1024;
153
154
  const MAX_SUMMARY_LINES = 20_000;
155
+ const MAX_ARTIFACT_RAW_INLINE_BYTES = DEFAULT_MAX_BYTES;
154
156
  /**
155
157
  * Per-line column cap for file reads. Lines wider than the value of
156
158
  * `tools.outputMaxColumns` are ellipsis-truncated at display time; the file
@@ -1519,6 +1521,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1519
1521
  displayMode: { hashLines: boolean; lineNumbers: boolean },
1520
1522
  suffixResolution: { from: string; to: string } | undefined,
1521
1523
  signal: AbortSignal | undefined,
1524
+ allowBridge = true,
1522
1525
  ): Promise<{
1523
1526
  outputText: string;
1524
1527
  columnTruncated: number;
@@ -1528,7 +1531,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1528
1531
  const rawSelector = isRawSelector(parsed);
1529
1532
 
1530
1533
  // ACP bridge first — the editor's in-memory buffer is source of truth.
1531
- const bridgePromise = this.#routeReadThroughBridge(absolutePath);
1534
+ const bridgePromise = allowBridge ? this.#routeReadThroughBridge(absolutePath) : undefined;
1532
1535
  if (bridgePromise !== undefined) {
1533
1536
  try {
1534
1537
  const bridgeText = await bridgePromise;
@@ -2802,6 +2805,197 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2802
2805
  return toolResult<ReadToolDetails>(details).text(summary).sourcePath(absolutePath).done();
2803
2806
  }
2804
2807
 
2808
+ #formatArtifactWorkflowNotice(artifact: ResolvedArtifactFile, artifactUrl: string): string {
2809
+ const displayPath = shortenPath(artifact.path);
2810
+ return `Artifact storage: ${displayPath} (${formatBytes(artifact.size)}). Use ${artifactUrl}:N-M to page, ${artifactUrl}:raw:N-M for verbatim chunks, and the artifact file path for search/copy workflows.`;
2811
+ }
2812
+
2813
+ #formatRawArtifactBlockedNotice(artifact: ResolvedArtifactFile, artifactUrl: string): string {
2814
+ const displayPath = shortenPath(artifact.path);
2815
+ return `Unbounded raw read blocked for ${artifactUrl} (${formatBytes(
2816
+ artifact.size,
2817
+ )}). Reading the whole artifact verbatim can exhaust memory. Use ${artifactUrl}:raw:1-3000 for bounded verbatim chunks, ${artifactUrl}:1-3000 for numbered exploration, and the artifact file path for search/copy workflows: ${displayPath}`;
2818
+ }
2819
+
2820
+ async #readArtifactFile(
2821
+ url: InternalUrl,
2822
+ parsedSel: ParsedSelector,
2823
+ signal?: AbortSignal,
2824
+ ): Promise<AgentToolResult<ReadToolDetails>> {
2825
+ const artifact = await resolveArtifactFile(url, {
2826
+ cwd: this.session.cwd,
2827
+ settings: this.session.settings,
2828
+ signal,
2829
+ localProtocolOptions: this.session.localProtocolOptions,
2830
+ skills: this.session.skills,
2831
+ });
2832
+ const artifactUrl = `artifact://${artifact.id}`;
2833
+ const details: ReadToolDetails = {
2834
+ resolvedPath: artifact.path,
2835
+ contentType: "text/plain",
2836
+ };
2837
+
2838
+ if (parsedSel.kind === "raw" && artifact.size > MAX_ARTIFACT_RAW_INLINE_BYTES) {
2839
+ return toolResult<ReadToolDetails>(details)
2840
+ .text(this.#formatRawArtifactBlockedNotice(artifact, artifactUrl))
2841
+ .sourcePath(artifact.path)
2842
+ .sourceInternal(url.href)
2843
+ .done();
2844
+ }
2845
+
2846
+ const rawSelector = isRawSelector(parsedSel);
2847
+ const displayMode = resolveFileDisplayMode(this.session, { raw: rawSelector, immutable: true });
2848
+ if (isMultiRange(parsedSel) && parsedSel.kind === "lines") {
2849
+ const read = await this.#readLocalFileMultiRange(
2850
+ artifact.path,
2851
+ parsedSel.ranges,
2852
+ artifact.size,
2853
+ parsedSel,
2854
+ displayMode,
2855
+ undefined,
2856
+ signal,
2857
+ false,
2858
+ );
2859
+ if (read.bridgeResult) return read.bridgeResult;
2860
+ if (read.displayContent) details.displayContent = read.displayContent;
2861
+ let text = read.outputText;
2862
+ if (!rawSelector && artifact.size > MAX_ARTIFACT_RAW_INLINE_BYTES) {
2863
+ text = text
2864
+ ? `${text}\n\n[${this.#formatArtifactWorkflowNotice(artifact, artifactUrl)}]`
2865
+ : this.#formatArtifactWorkflowNotice(artifact, artifactUrl);
2866
+ }
2867
+ const resultBuilder = toolResult<ReadToolDetails>(details)
2868
+ .text(text)
2869
+ .sourcePath(artifact.path)
2870
+ .sourceInternal(url.href);
2871
+ if (read.columnTruncated > 0) resultBuilder.limits({ columnMax: read.columnTruncated });
2872
+ return resultBuilder.done();
2873
+ }
2874
+
2875
+ const { offset, limit } = selToOffsetLimit(parsedSel);
2876
+ const requestedStart = offset ? Math.max(0, offset - 1) : 0;
2877
+ const expandStart = offset !== undefined && offset > 1;
2878
+ const expandEnd = limit !== undefined;
2879
+ const leadingContext = expandStart ? Math.min(requestedStart, RANGE_LEADING_CONTEXT_LINES) : 0;
2880
+ const trailingContext = expandEnd ? RANGE_TRAILING_CONTEXT_LINES : 0;
2881
+ const startLine = requestedStart - leadingContext;
2882
+ const startLineDisplay = startLine + 1;
2883
+ const effectiveLimit = limit ?? this.#defaultLimit;
2884
+ const maxLinesToCollect = Math.min(effectiveLimit + leadingContext + trailingContext, DEFAULT_MAX_LINES);
2885
+ const selectedLineLimit = effectiveLimit + leadingContext + trailingContext;
2886
+ const maxBytesForRead = Math.max(DEFAULT_MAX_BYTES, maxLinesToCollect * 512);
2887
+ const streamResult = await streamLinesFromFile(
2888
+ artifact.path,
2889
+ startLine,
2890
+ maxLinesToCollect,
2891
+ maxBytesForRead,
2892
+ selectedLineLimit,
2893
+ signal,
2894
+ artifact.size > SNAPSHOT_MAX_BYTES,
2895
+ );
2896
+ const {
2897
+ lines: collectedLines,
2898
+ totalFileLines,
2899
+ collectedBytes,
2900
+ stoppedByByteLimit,
2901
+ firstLinePreview,
2902
+ firstLineByteLength,
2903
+ reachedEof,
2904
+ } = streamResult;
2905
+
2906
+ if (requestedStart >= totalFileLines) {
2907
+ const suggestion =
2908
+ totalFileLines === 0
2909
+ ? "The artifact is empty."
2910
+ : `Use ${artifactUrl}:1 to read from the start, or ${artifactUrl}:${totalFileLines} to read the last line.`;
2911
+ return toolResult<ReadToolDetails>(details)
2912
+ .text(`Line ${requestedStart + 1} is beyond end of artifact (${totalFileLines} lines total). ${suggestion}`)
2913
+ .sourcePath(artifact.path)
2914
+ .sourceInternal(url.href)
2915
+ .done();
2916
+ }
2917
+
2918
+ const shouldAddLineNumbers = rawSelector ? false : displayMode.hashLines ? false : displayMode.lineNumbers;
2919
+ const selectedContent = collectedLines.join("\n");
2920
+ const totalSelectedLines = totalFileLines - startLine;
2921
+ const wasTruncated = collectedLines.length < totalSelectedLines || stoppedByByteLimit;
2922
+ const firstLineExceedsLimit = firstLineByteLength !== undefined && firstLineByteLength > maxBytesForRead;
2923
+ const truncation: TruncationResult = {
2924
+ content: selectedContent,
2925
+ truncated: wasTruncated,
2926
+ truncatedBy: stoppedByByteLimit ? "bytes" : wasTruncated ? "lines" : undefined,
2927
+ totalLines: totalSelectedLines,
2928
+ totalBytes: collectedBytes,
2929
+ outputLines: collectedLines.length,
2930
+ outputBytes: collectedBytes,
2931
+ lastLinePartial: false,
2932
+ firstLineExceedsLimit,
2933
+ };
2934
+
2935
+ let displayContent: { text: string; startLine: number; lineNumbers?: Array<number | null> } | undefined;
2936
+ const formatText = (text: string, startNum: number): string => {
2937
+ const lineCount = countTextLines(text);
2938
+ displayContent = {
2939
+ text,
2940
+ startLine: startNum,
2941
+ lineNumbers: Array.from({ length: lineCount }, (_, i) => startNum + i),
2942
+ };
2943
+ return formatTextWithMode(text, startNum, false, shouldAddLineNumbers);
2944
+ };
2945
+
2946
+ let outputText: string;
2947
+ let truncationInfo:
2948
+ | { result: TruncationResult; options: { direction: "head"; startLine?: number; totalFileLines?: number } }
2949
+ | undefined;
2950
+ if (truncation.firstLineExceedsLimit) {
2951
+ const firstLineBytes = firstLineByteLength ?? 0;
2952
+ const snippet = firstLinePreview ?? { text: "", bytes: 0 };
2953
+ outputText =
2954
+ snippet.text.length > 0
2955
+ ? formatText(snippet.text, startLineDisplay)
2956
+ : `[Line ${startLineDisplay} is ${formatBytes(
2957
+ firstLineBytes,
2958
+ )}, exceeds ${formatBytes(maxBytesForRead)} limit. Unable to display a valid UTF-8 snippet.]`;
2959
+ truncationInfo = {
2960
+ result: truncation,
2961
+ options: {
2962
+ direction: "head",
2963
+ startLine: startLineDisplay,
2964
+ totalFileLines: reachedEof ? totalFileLines : undefined,
2965
+ },
2966
+ };
2967
+ } else {
2968
+ outputText = formatText(truncation.content, startLineDisplay);
2969
+ if (truncation.truncated) {
2970
+ truncationInfo = {
2971
+ result: truncation,
2972
+ options: {
2973
+ direction: "head",
2974
+ startLine: startLineDisplay,
2975
+ totalFileLines: reachedEof ? totalFileLines : undefined,
2976
+ },
2977
+ };
2978
+ } else if (startLine + collectedLines.length < totalFileLines || !reachedEof) {
2979
+ const nextOffset = startLine + collectedLines.length + 1;
2980
+ outputText += reachedEof
2981
+ ? `\n\n[${totalFileLines - (startLine + collectedLines.length)} more lines in artifact. Use ${artifactUrl}:${nextOffset} to continue]`
2982
+ : `\n\n[More lines in artifact (${formatBytes(artifact.size)} total; not scanned to EOF). Use ${artifactUrl}:${nextOffset} to continue]`;
2983
+ }
2984
+ }
2985
+
2986
+ if (!rawSelector && artifact.size > MAX_ARTIFACT_RAW_INLINE_BYTES) {
2987
+ outputText += `\n\n[${this.#formatArtifactWorkflowNotice(artifact, artifactUrl)}]`;
2988
+ }
2989
+ if (displayContent) details.displayContent = displayContent;
2990
+ if (truncationInfo) details.truncation = truncationInfo.result;
2991
+ const resultBuilder = toolResult<ReadToolDetails>(details)
2992
+ .text(outputText)
2993
+ .sourcePath(artifact.path)
2994
+ .sourceInternal(url.href);
2995
+ if (truncationInfo) resultBuilder.truncation(truncationInfo.result, truncationInfo.options);
2996
+ return resultBuilder.done();
2997
+ }
2998
+
2805
2999
  /**
2806
3000
  * Handle internal URLs (agent://, artifact://, memory://, skill://, rule://, local://, mcp://).
2807
3001
  * Supports pagination via offset/limit but rejects them when query extraction is used.
@@ -2829,6 +3023,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2829
3023
  const hasQueryExtraction = queryParam !== null && queryParam !== "";
2830
3024
  hasExtraction = hasPathExtraction || hasQueryExtraction;
2831
3025
  }
3026
+ if (scheme === "artifact") {
3027
+ return this.#readArtifactFile(urlMeta, parsedSel, signal);
3028
+ }
2832
3029
 
2833
3030
  // local:// files are real on-disk paths. Detect image files and emit a
2834
3031
  // decoded image block before the text-only resource contract UTF-8
package/src/utils/git.ts CHANGED
@@ -1726,6 +1726,26 @@ export const cherryPick = Object.assign(
1726
1726
  async abort(cwd: string, signal?: AbortSignal): Promise<void> {
1727
1727
  await runEffect(cwd, ["cherry-pick", "--abort"], { signal });
1728
1728
  },
1729
+ /**
1730
+ * Skip the current commit of an in-progress cherry-pick sequence and
1731
+ * continue with the rest of the range. Use after {@link isEmptyError}
1732
+ * reports the current attempt collapsed to a no-op — the alternative,
1733
+ * `--abort`, throws away every remaining commit in the range.
1734
+ */
1735
+ async skip(cwd: string, signal?: AbortSignal): Promise<void> {
1736
+ await runEffect(cwd, ["cherry-pick", "--skip"], { signal });
1737
+ },
1738
+ /**
1739
+ * True when a cherry-pick failure was caused by the current commit
1740
+ * being empty against HEAD — either redundant with an already-applied
1741
+ * change, or auto-resolved to HEAD by a 3-way merge. Callers should
1742
+ * `--skip` in this case to advance the sequencer rather than aborting
1743
+ * the whole range: an empty commit is not a merge conflict, and any
1744
+ * later commits in the range still deserve to land.
1745
+ */
1746
+ isEmptyError(err: unknown): boolean {
1747
+ return err instanceof GitCommandError && /the previous cherry-pick is now empty/i.test(err.result.stderr);
1748
+ },
1729
1749
  },
1730
1750
  );
1731
1751
 
package/src/utils/open.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import * as url from "node:url";
4
- import * as piUtils from "@oh-my-pi/pi-utils";
4
+ import { $which, logger } from "@oh-my-pi/pi-utils";
5
5
 
6
6
  const URL_SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z\d+.-]*:/;
7
7
 
@@ -9,7 +9,7 @@ function getExistingWslLocalPath(urlOrPath: string): string | undefined {
9
9
  if (
10
10
  process.platform !== "linux" ||
11
11
  !(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) ||
12
- !piUtils.$which("wslview")
12
+ !$which("wslview")
13
13
  ) {
14
14
  return undefined;
15
15
  }
@@ -31,6 +31,24 @@ function getExistingWslLocalPath(urlOrPath: string): string | undefined {
31
31
  }
32
32
  }
33
33
 
34
+ /**
35
+ * Resolve the Windows `rundll32.exe` command used to hand a URL/path to the
36
+ * user's registered protocol handler. Anchoring to `%SystemRoot%\System32`
37
+ * (rather than relying on `rundll32` being on `PATH`) survives environments
38
+ * where the machine `PATH` no longer references `System32` — a common
39
+ * real-world misconfiguration where `System32\Wbem` / `WindowsPowerShell` /
40
+ * `OpenSSH` survive but `System32` itself is dropped. Bare `rundll32` on
41
+ * such boxes throws `Executable not found in $PATH: "rundll32"` from
42
+ * `Bun.spawn` before ShellExecute ever sees the URL.
43
+ */
44
+ function windowsOpenerCommand(target: string): string[] {
45
+ const systemRoot = process.env.SystemRoot?.trim() || process.env.SYSTEMROOT?.trim() || "C:\\Windows";
46
+ // `path.win32` (not the platform-adaptive `path.join`) keeps Windows path
47
+ // separators when tests run under a POSIX host and matches Windows call
48
+ // conventions on the real target.
49
+ const rundll32 = path.win32.join(systemRoot, "System32", "rundll32.exe");
50
+ return [rundll32, "url.dll,FileProtocolHandler", target];
51
+ }
34
52
  /** Open a URL or file path in the default browser/application. Best-effort, never throws. */
35
53
  export function openPath(urlOrPath: string): void {
36
54
  let cmd: string[];
@@ -39,7 +57,7 @@ export function openPath(urlOrPath: string): void {
39
57
  cmd = ["open", urlOrPath];
40
58
  break;
41
59
  case "win32":
42
- cmd = ["rundll32", "url.dll,FileProtocolHandler", urlOrPath];
60
+ cmd = windowsOpenerCommand(urlOrPath);
43
61
  break;
44
62
  default: {
45
63
  const wslPath = getExistingWslLocalPath(urlOrPath);
@@ -47,9 +65,36 @@ export function openPath(urlOrPath: string): void {
47
65
  break;
48
66
  }
49
67
  }
68
+ let child: Bun.Subprocess | undefined;
50
69
  try {
51
- Bun.spawn(cmd, { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
52
- } catch {
53
- // Best-effort: browser opening is non-critical
70
+ child = Bun.spawn(cmd, { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
71
+ } catch (error) {
72
+ // Spawn threw synchronously (missing binary, denied exec, sandbox
73
+ // restriction, …). Best-effort: log so the failure isn't invisible while
74
+ // still letting the caller advertise a copy-URL fallback.
75
+ logger.warn("Failed to open external URL/path", {
76
+ command: cmd[0],
77
+ target: urlOrPath,
78
+ error: error instanceof Error ? error.message : String(error),
79
+ });
80
+ return;
54
81
  }
82
+ // Detect delayed failures (exec succeeded but the opener exited non-zero)
83
+ // without blocking the caller. Recording them makes silent misconfigurations
84
+ // (e.g. `xdg-open` present but no MIME handler for `https`) diagnosable from
85
+ // `~/.omp/logs/omp.*.log`.
86
+ child.exited.then(
87
+ exitCode => {
88
+ if (typeof exitCode === "number" && exitCode !== 0) {
89
+ logger.warn("External opener exited with non-zero status", {
90
+ command: cmd[0],
91
+ target: urlOrPath,
92
+ exitCode,
93
+ });
94
+ }
95
+ },
96
+ () => {
97
+ // Ignore — awaiting the subprocess is best-effort telemetry.
98
+ },
99
+ );
55
100
  }