@oh-my-pi/pi-coding-agent 16.3.4 → 16.3.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 (88) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/cli.js +3522 -3485
  3. package/dist/types/edit/file-snapshot-store.d.ts +4 -2
  4. package/dist/types/edit/renderer.d.ts +0 -1
  5. package/dist/types/extensibility/custom-tools/types.d.ts +2 -0
  6. package/dist/types/extensibility/shared-events.d.ts +8 -1
  7. package/dist/types/hindsight/content.d.ts +4 -0
  8. package/dist/types/internal-urls/artifact-protocol.d.ts +8 -0
  9. package/dist/types/internal-urls/types.d.ts +10 -0
  10. package/dist/types/lsp/config.d.ts +4 -0
  11. package/dist/types/lsp/edits.d.ts +2 -0
  12. package/dist/types/mcp/oauth-discovery.d.ts +30 -0
  13. package/dist/types/modes/components/assistant-message.d.ts +15 -10
  14. package/dist/types/modes/components/bash-execution.d.ts +6 -0
  15. package/dist/types/modes/components/eval-execution.d.ts +6 -0
  16. package/dist/types/modes/components/login-dialog.d.ts +10 -2
  17. package/dist/types/modes/components/tool-execution.d.ts +3 -13
  18. package/dist/types/modes/components/transcript-container.d.ts +26 -29
  19. package/dist/types/modes/controllers/mcp-command-controller.d.ts +20 -3
  20. package/dist/types/modes/rpc/rpc-client.d.ts +7 -3
  21. package/dist/types/modes/rpc/rpc-types.d.ts +6 -0
  22. package/dist/types/modes/utils/transcript-render-helpers.d.ts +15 -6
  23. package/dist/types/session/agent-session.d.ts +2 -0
  24. package/dist/types/subprocess/worker-runtime.d.ts +11 -0
  25. package/dist/types/tools/bash-skill-urls.d.ts +2 -2
  26. package/dist/types/tools/bash.d.ts +0 -2
  27. package/dist/types/tools/browser/tab-supervisor.d.ts +10 -0
  28. package/dist/types/tools/eval-render.d.ts +0 -2
  29. package/dist/types/tools/renderers.d.ts +0 -20
  30. package/dist/types/tools/ssh.d.ts +0 -2
  31. package/dist/types/tools/write.d.ts +1 -1
  32. package/dist/types/utils/git.d.ts +16 -0
  33. package/package.json +12 -12
  34. package/src/cli/auth-broker-cli.ts +13 -2
  35. package/src/cli/tiny-models-cli.ts +12 -5
  36. package/src/edit/file-snapshot-store.ts +5 -2
  37. package/src/edit/renderer.ts +0 -5
  38. package/src/extensibility/custom-tools/types.ts +2 -0
  39. package/src/extensibility/shared-events.ts +9 -1
  40. package/src/hindsight/content.ts +31 -0
  41. package/src/internal-urls/artifact-protocol.ts +97 -53
  42. package/src/internal-urls/types.ts +10 -0
  43. package/src/lsp/config.ts +15 -0
  44. package/src/lsp/edits.ts +28 -7
  45. package/src/lsp/index.ts +46 -4
  46. package/src/mcp/oauth-discovery.ts +88 -18
  47. package/src/mnemopi/state.ts +26 -2
  48. package/src/modes/components/assistant-message.ts +134 -82
  49. package/src/modes/components/bash-execution.ts +9 -0
  50. package/src/modes/components/chat-transcript-builder.ts +8 -4
  51. package/src/modes/components/eval-execution.ts +9 -0
  52. package/src/modes/components/login-dialog.ts +16 -2
  53. package/src/modes/components/mcp-add-wizard.ts +9 -1
  54. package/src/modes/components/tool-execution.ts +4 -50
  55. package/src/modes/components/transcript-container.ts +82 -458
  56. package/src/modes/components/tree-selector.ts +9 -3
  57. package/src/modes/controllers/command-controller.ts +0 -3
  58. package/src/modes/controllers/event-controller.ts +74 -14
  59. package/src/modes/controllers/extension-ui-controller.ts +0 -1
  60. package/src/modes/controllers/input-controller.ts +4 -10
  61. package/src/modes/controllers/mcp-command-controller.ts +106 -29
  62. package/src/modes/controllers/selector-controller.ts +9 -1
  63. package/src/modes/interactive-mode.ts +12 -8
  64. package/src/modes/rpc/rpc-client.ts +8 -4
  65. package/src/modes/rpc/rpc-mode.ts +1 -0
  66. package/src/modes/rpc/rpc-types.ts +13 -1
  67. package/src/modes/setup-wizard/scenes/sign-in.ts +18 -0
  68. package/src/modes/utils/transcript-render-helpers.ts +40 -13
  69. package/src/modes/utils/ui-helpers.ts +12 -7
  70. package/src/prompts/tools/read.md +1 -1
  71. package/src/sdk.ts +1 -0
  72. package/src/session/agent-session.ts +148 -1
  73. package/src/session/session-context.ts +7 -0
  74. package/src/subprocess/worker-runtime.ts +219 -2
  75. package/src/task/worktree.ts +28 -6
  76. package/src/tiny/worker.ts +14 -4
  77. package/src/tools/bash-skill-urls.ts +3 -3
  78. package/src/tools/bash.ts +0 -4
  79. package/src/tools/browser/tab-supervisor.ts +47 -3
  80. package/src/tools/eval-render.ts +0 -20
  81. package/src/tools/grep.ts +19 -2
  82. package/src/tools/path-utils.ts +4 -0
  83. package/src/tools/read.ts +261 -21
  84. package/src/tools/renderers.ts +0 -20
  85. package/src/tools/ssh.ts +0 -16
  86. package/src/tools/write.ts +13 -6
  87. package/src/utils/git.ts +20 -0
  88. package/src/utils/open.ts +51 -6
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
@@ -332,6 +334,24 @@ function contiguousLineNumbers(startLine: number, count: number): number[] {
332
334
  return lines;
333
335
  }
334
336
 
337
+ function lineNumbersFromSpans(spans: readonly { startLine: number; endLine: number }[]): number[] {
338
+ const lines: number[] = [];
339
+ for (const span of spans) {
340
+ for (let line = span.startLine; line <= span.endLine; line++) lines.push(line);
341
+ }
342
+ return lines;
343
+ }
344
+
345
+ function recordInMemorySeenLines(
346
+ session: ToolSession,
347
+ absolutePath: string | undefined,
348
+ fullText: string,
349
+ seenLines: readonly number[] | undefined,
350
+ ): void {
351
+ if (!absolutePath || !path.isAbsolute(absolutePath) || !seenLines || seenLines.length === 0) return;
352
+ getFileSnapshotStore(session).record(canonicalSnapshotKey(absolutePath), normalizeToLF(fullText), seenLines);
353
+ }
354
+
335
355
  function lineNumbersFromEntries(entries: readonly LineEntry[]): number[] {
336
356
  const lines: number[] = [];
337
357
  for (const entry of entries) {
@@ -1317,6 +1337,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1317
1337
  : undefined;
1318
1338
  let emittedHashlineHeader = false;
1319
1339
  let seenLines: number[] | undefined;
1340
+ let rawSeenLines: number[] | undefined;
1320
1341
  const formatText = (content: string, startNum: number): string => {
1321
1342
  const lineCount = countTextLines(content);
1322
1343
  details.displayContent = {
@@ -1380,10 +1401,12 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1380
1401
  } else if (truncation.truncated) {
1381
1402
  const outputLines = truncation.outputLines ?? countTextLines(truncation.content);
1382
1403
  const endLineDisplay = startLineDisplay + Math.max(0, outputLines - 1);
1383
- outputText =
1384
- options.raw === true
1385
- ? formatText(truncation.content, startLineDisplay)
1386
- : formatLineEntries(buildLineEntries(endLineDisplay), startLineDisplay);
1404
+ if (options.raw === true) {
1405
+ rawSeenLines = contiguousLineNumbers(startLineDisplay, outputLines);
1406
+ outputText = formatText(truncation.content, startLineDisplay);
1407
+ } else {
1408
+ outputText = formatLineEntries(buildLineEntries(endLineDisplay), startLineDisplay);
1409
+ }
1387
1410
  details.truncation = truncation;
1388
1411
  truncationInfo = {
1389
1412
  result: truncation,
@@ -1393,21 +1416,28 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1393
1416
  const remaining = allLines.length - (startLine + userLimitedLines);
1394
1417
  const nextOffset = startLine + userLimitedLines + 1;
1395
1418
 
1396
- outputText =
1397
- options.raw === true
1398
- ? formatText(selectedContent, startLineDisplay)
1399
- : formatLineEntries(buildLineEntries(endLine), startLineDisplay);
1419
+ if (options.raw === true) {
1420
+ rawSeenLines = contiguousLineNumbers(startLineDisplay, userLimitedLines);
1421
+ outputText = formatText(selectedContent, startLineDisplay);
1422
+ } else {
1423
+ outputText = formatLineEntries(buildLineEntries(endLine), startLineDisplay);
1424
+ }
1400
1425
  outputText += `\n\n[${remaining} more lines in ${options.entityLabel}. Use :${nextOffset} to continue]`;
1401
1426
  } else {
1402
- outputText =
1403
- options.raw === true
1404
- ? formatText(truncation.content, startLineDisplay)
1405
- : formatLineEntries(buildLineEntries(endLine), startLineDisplay);
1427
+ if (options.raw === true) {
1428
+ rawSeenLines = contiguousLineNumbers(startLineDisplay, endLine - startLine);
1429
+ outputText = formatText(truncation.content, startLineDisplay);
1430
+ } else {
1431
+ outputText = formatLineEntries(buildLineEntries(endLine), startLineDisplay);
1432
+ }
1406
1433
  }
1407
1434
 
1408
1435
  if (hashContext?.tag && options.sourcePath && seenLines) {
1409
1436
  recordSeenLines(this.session, options.sourcePath, hashContext.tag, seenLines);
1410
1437
  }
1438
+ if (options.raw === true && options.sourcePath && options.immutable !== true && rawSeenLines) {
1439
+ recordInMemorySeenLines(this.session, options.sourcePath, text, rawSeenLines);
1440
+ }
1411
1441
  resultBuilder.text(outputText);
1412
1442
  if (truncationInfo) {
1413
1443
  resultBuilder.truncation(truncationInfo.result, truncationInfo.options);
@@ -1501,6 +1531,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1501
1531
  if (hashContext?.tag && options.sourcePath && seenLines) {
1502
1532
  recordSeenLines(this.session, options.sourcePath, hashContext.tag, seenLines);
1503
1533
  }
1534
+ if (options.raw === true && options.sourcePath && options.immutable !== true && visibleSpans.length > 0) {
1535
+ recordInMemorySeenLines(this.session, options.sourcePath, text, lineNumbersFromSpans(visibleSpans));
1536
+ }
1504
1537
  resultBuilder.text(finalText);
1505
1538
  return resultBuilder.done();
1506
1539
  }
@@ -1519,6 +1552,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1519
1552
  displayMode: { hashLines: boolean; lineNumbers: boolean },
1520
1553
  suffixResolution: { from: string; to: string } | undefined,
1521
1554
  signal: AbortSignal | undefined,
1555
+ allowBridge = true,
1522
1556
  ): Promise<{
1523
1557
  outputText: string;
1524
1558
  columnTruncated: number;
@@ -1528,7 +1562,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1528
1562
  const rawSelector = isRawSelector(parsed);
1529
1563
 
1530
1564
  // ACP bridge first — the editor's in-memory buffer is source of truth.
1531
- const bridgePromise = this.#routeReadThroughBridge(absolutePath);
1565
+ const bridgePromise = allowBridge ? this.#routeReadThroughBridge(absolutePath) : undefined;
1532
1566
  if (bridgePromise !== undefined) {
1533
1567
  try {
1534
1568
  const bridgeText = await bridgePromise;
@@ -1612,14 +1646,16 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1612
1646
  }
1613
1647
  if (cloned) displayLines = cloned;
1614
1648
  }
1615
- const endLine = range.startLine + Math.max(0, displayLines.length - 1);
1616
- visibleSpans.push({ startLine: range.startLine, endLine });
1617
- for (let i = 0; i < displayLines.length; i++) {
1618
- displayLineByNumber.set(range.startLine + i, displayLines[i] ?? "");
1619
- }
1620
- if (!fullLines || rawSelector) {
1621
- const blockText = displayLines.join("\n");
1622
- blocks.push(formatTextWithMode(blockText, range.startLine, shouldAddHashLines, shouldAddLineNumbers));
1649
+ if (displayLines.length > 0) {
1650
+ const endLine = range.startLine + displayLines.length - 1;
1651
+ visibleSpans.push({ startLine: range.startLine, endLine });
1652
+ for (let i = 0; i < displayLines.length; i++) {
1653
+ displayLineByNumber.set(range.startLine + i, displayLines[i] ?? "");
1654
+ }
1655
+ if (!fullLines || rawSelector) {
1656
+ const blockText = displayLines.join("\n");
1657
+ blocks.push(formatTextWithMode(blockText, range.startLine, shouldAddHashLines, shouldAddLineNumbers));
1658
+ }
1623
1659
  }
1624
1660
  }
1625
1661
 
@@ -1659,6 +1695,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1659
1695
  recordSeenLinesFromBody(this.session, absolutePath, tag, outputText, clippedLines);
1660
1696
  outputText = `${formatReadHashlineHeader(formatPathRelativeToCwd(absolutePath, this.session.cwd), tag)}\n${outputText}`;
1661
1697
  }
1698
+ } else if (rawSelector && visibleSpans.length > 0) {
1699
+ const rawSeenLines = lineNumbersFromSpans(visibleSpans);
1700
+ if (rawSeenLines.length > 0) await recordFileSnapshot(this.session, absolutePath, rawSeenLines);
1662
1701
  }
1663
1702
  if (notices.length > 0) {
1664
1703
  outputText = outputText ? `${outputText}\n${notices.join("\n")}` : notices.join("\n");
@@ -2666,6 +2705,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2666
2705
  if (hashContext?.tag) {
2667
2706
  recordSeenLinesFromBody(this.session, absolutePath, hashContext.tag, outputText, clippedLines);
2668
2707
  }
2708
+ if (rawSelector && !firstLineExceedsLimit && collectedLines.length > 0) {
2709
+ await recordFileSnapshot(
2710
+ this.session,
2711
+ absolutePath,
2712
+ contiguousLineNumbers(startLineDisplay, collectedLines.length),
2713
+ );
2714
+ }
2669
2715
 
2670
2716
  if (capturedDisplayContent) {
2671
2717
  details.displayContent = capturedDisplayContent;
@@ -2802,6 +2848,197 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2802
2848
  return toolResult<ReadToolDetails>(details).text(summary).sourcePath(absolutePath).done();
2803
2849
  }
2804
2850
 
2851
+ #formatArtifactWorkflowNotice(artifact: ResolvedArtifactFile, artifactUrl: string): string {
2852
+ const displayPath = shortenPath(artifact.path);
2853
+ 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.`;
2854
+ }
2855
+
2856
+ #formatRawArtifactBlockedNotice(artifact: ResolvedArtifactFile, artifactUrl: string): string {
2857
+ const displayPath = shortenPath(artifact.path);
2858
+ return `Unbounded raw read blocked for ${artifactUrl} (${formatBytes(
2859
+ artifact.size,
2860
+ )}). 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}`;
2861
+ }
2862
+
2863
+ async #readArtifactFile(
2864
+ url: InternalUrl,
2865
+ parsedSel: ParsedSelector,
2866
+ signal?: AbortSignal,
2867
+ ): Promise<AgentToolResult<ReadToolDetails>> {
2868
+ const artifact = await resolveArtifactFile(url, {
2869
+ cwd: this.session.cwd,
2870
+ settings: this.session.settings,
2871
+ signal,
2872
+ localProtocolOptions: this.session.localProtocolOptions,
2873
+ skills: this.session.skills,
2874
+ });
2875
+ const artifactUrl = `artifact://${artifact.id}`;
2876
+ const details: ReadToolDetails = {
2877
+ resolvedPath: artifact.path,
2878
+ contentType: "text/plain",
2879
+ };
2880
+
2881
+ if (parsedSel.kind === "raw" && artifact.size > MAX_ARTIFACT_RAW_INLINE_BYTES) {
2882
+ return toolResult<ReadToolDetails>(details)
2883
+ .text(this.#formatRawArtifactBlockedNotice(artifact, artifactUrl))
2884
+ .sourcePath(artifact.path)
2885
+ .sourceInternal(url.href)
2886
+ .done();
2887
+ }
2888
+
2889
+ const rawSelector = isRawSelector(parsedSel);
2890
+ const displayMode = resolveFileDisplayMode(this.session, { raw: rawSelector, immutable: true });
2891
+ if (isMultiRange(parsedSel) && parsedSel.kind === "lines") {
2892
+ const read = await this.#readLocalFileMultiRange(
2893
+ artifact.path,
2894
+ parsedSel.ranges,
2895
+ artifact.size,
2896
+ parsedSel,
2897
+ displayMode,
2898
+ undefined,
2899
+ signal,
2900
+ false,
2901
+ );
2902
+ if (read.bridgeResult) return read.bridgeResult;
2903
+ if (read.displayContent) details.displayContent = read.displayContent;
2904
+ let text = read.outputText;
2905
+ if (!rawSelector && artifact.size > MAX_ARTIFACT_RAW_INLINE_BYTES) {
2906
+ text = text
2907
+ ? `${text}\n\n[${this.#formatArtifactWorkflowNotice(artifact, artifactUrl)}]`
2908
+ : this.#formatArtifactWorkflowNotice(artifact, artifactUrl);
2909
+ }
2910
+ const resultBuilder = toolResult<ReadToolDetails>(details)
2911
+ .text(text)
2912
+ .sourcePath(artifact.path)
2913
+ .sourceInternal(url.href);
2914
+ if (read.columnTruncated > 0) resultBuilder.limits({ columnMax: read.columnTruncated });
2915
+ return resultBuilder.done();
2916
+ }
2917
+
2918
+ const { offset, limit } = selToOffsetLimit(parsedSel);
2919
+ const requestedStart = offset ? Math.max(0, offset - 1) : 0;
2920
+ const expandStart = offset !== undefined && offset > 1;
2921
+ const expandEnd = limit !== undefined;
2922
+ const leadingContext = expandStart ? Math.min(requestedStart, RANGE_LEADING_CONTEXT_LINES) : 0;
2923
+ const trailingContext = expandEnd ? RANGE_TRAILING_CONTEXT_LINES : 0;
2924
+ const startLine = requestedStart - leadingContext;
2925
+ const startLineDisplay = startLine + 1;
2926
+ const effectiveLimit = limit ?? this.#defaultLimit;
2927
+ const maxLinesToCollect = Math.min(effectiveLimit + leadingContext + trailingContext, DEFAULT_MAX_LINES);
2928
+ const selectedLineLimit = effectiveLimit + leadingContext + trailingContext;
2929
+ const maxBytesForRead = Math.max(DEFAULT_MAX_BYTES, maxLinesToCollect * 512);
2930
+ const streamResult = await streamLinesFromFile(
2931
+ artifact.path,
2932
+ startLine,
2933
+ maxLinesToCollect,
2934
+ maxBytesForRead,
2935
+ selectedLineLimit,
2936
+ signal,
2937
+ artifact.size > SNAPSHOT_MAX_BYTES,
2938
+ );
2939
+ const {
2940
+ lines: collectedLines,
2941
+ totalFileLines,
2942
+ collectedBytes,
2943
+ stoppedByByteLimit,
2944
+ firstLinePreview,
2945
+ firstLineByteLength,
2946
+ reachedEof,
2947
+ } = streamResult;
2948
+
2949
+ if (requestedStart >= totalFileLines) {
2950
+ const suggestion =
2951
+ totalFileLines === 0
2952
+ ? "The artifact is empty."
2953
+ : `Use ${artifactUrl}:1 to read from the start, or ${artifactUrl}:${totalFileLines} to read the last line.`;
2954
+ return toolResult<ReadToolDetails>(details)
2955
+ .text(`Line ${requestedStart + 1} is beyond end of artifact (${totalFileLines} lines total). ${suggestion}`)
2956
+ .sourcePath(artifact.path)
2957
+ .sourceInternal(url.href)
2958
+ .done();
2959
+ }
2960
+
2961
+ const shouldAddLineNumbers = rawSelector ? false : displayMode.hashLines ? false : displayMode.lineNumbers;
2962
+ const selectedContent = collectedLines.join("\n");
2963
+ const totalSelectedLines = totalFileLines - startLine;
2964
+ const wasTruncated = collectedLines.length < totalSelectedLines || stoppedByByteLimit;
2965
+ const firstLineExceedsLimit = firstLineByteLength !== undefined && firstLineByteLength > maxBytesForRead;
2966
+ const truncation: TruncationResult = {
2967
+ content: selectedContent,
2968
+ truncated: wasTruncated,
2969
+ truncatedBy: stoppedByByteLimit ? "bytes" : wasTruncated ? "lines" : undefined,
2970
+ totalLines: totalSelectedLines,
2971
+ totalBytes: collectedBytes,
2972
+ outputLines: collectedLines.length,
2973
+ outputBytes: collectedBytes,
2974
+ lastLinePartial: false,
2975
+ firstLineExceedsLimit,
2976
+ };
2977
+
2978
+ let displayContent: { text: string; startLine: number; lineNumbers?: Array<number | null> } | undefined;
2979
+ const formatText = (text: string, startNum: number): string => {
2980
+ const lineCount = countTextLines(text);
2981
+ displayContent = {
2982
+ text,
2983
+ startLine: startNum,
2984
+ lineNumbers: Array.from({ length: lineCount }, (_, i) => startNum + i),
2985
+ };
2986
+ return formatTextWithMode(text, startNum, false, shouldAddLineNumbers);
2987
+ };
2988
+
2989
+ let outputText: string;
2990
+ let truncationInfo:
2991
+ | { result: TruncationResult; options: { direction: "head"; startLine?: number; totalFileLines?: number } }
2992
+ | undefined;
2993
+ if (truncation.firstLineExceedsLimit) {
2994
+ const firstLineBytes = firstLineByteLength ?? 0;
2995
+ const snippet = firstLinePreview ?? { text: "", bytes: 0 };
2996
+ outputText =
2997
+ snippet.text.length > 0
2998
+ ? formatText(snippet.text, startLineDisplay)
2999
+ : `[Line ${startLineDisplay} is ${formatBytes(
3000
+ firstLineBytes,
3001
+ )}, exceeds ${formatBytes(maxBytesForRead)} limit. Unable to display a valid UTF-8 snippet.]`;
3002
+ truncationInfo = {
3003
+ result: truncation,
3004
+ options: {
3005
+ direction: "head",
3006
+ startLine: startLineDisplay,
3007
+ totalFileLines: reachedEof ? totalFileLines : undefined,
3008
+ },
3009
+ };
3010
+ } else {
3011
+ outputText = formatText(truncation.content, startLineDisplay);
3012
+ if (truncation.truncated) {
3013
+ truncationInfo = {
3014
+ result: truncation,
3015
+ options: {
3016
+ direction: "head",
3017
+ startLine: startLineDisplay,
3018
+ totalFileLines: reachedEof ? totalFileLines : undefined,
3019
+ },
3020
+ };
3021
+ } else if (startLine + collectedLines.length < totalFileLines || !reachedEof) {
3022
+ const nextOffset = startLine + collectedLines.length + 1;
3023
+ outputText += reachedEof
3024
+ ? `\n\n[${totalFileLines - (startLine + collectedLines.length)} more lines in artifact. Use ${artifactUrl}:${nextOffset} to continue]`
3025
+ : `\n\n[More lines in artifact (${formatBytes(artifact.size)} total; not scanned to EOF). Use ${artifactUrl}:${nextOffset} to continue]`;
3026
+ }
3027
+ }
3028
+
3029
+ if (!rawSelector && artifact.size > MAX_ARTIFACT_RAW_INLINE_BYTES) {
3030
+ outputText += `\n\n[${this.#formatArtifactWorkflowNotice(artifact, artifactUrl)}]`;
3031
+ }
3032
+ if (displayContent) details.displayContent = displayContent;
3033
+ if (truncationInfo) details.truncation = truncationInfo.result;
3034
+ const resultBuilder = toolResult<ReadToolDetails>(details)
3035
+ .text(outputText)
3036
+ .sourcePath(artifact.path)
3037
+ .sourceInternal(url.href);
3038
+ if (truncationInfo) resultBuilder.truncation(truncationInfo.result, truncationInfo.options);
3039
+ return resultBuilder.done();
3040
+ }
3041
+
2805
3042
  /**
2806
3043
  * Handle internal URLs (agent://, artifact://, memory://, skill://, rule://, local://, mcp://).
2807
3044
  * Supports pagination via offset/limit but rejects them when query extraction is used.
@@ -2829,6 +3066,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2829
3066
  const hasQueryExtraction = queryParam !== null && queryParam !== "";
2830
3067
  hasExtraction = hasPathExtraction || hasQueryExtraction;
2831
3068
  }
3069
+ if (scheme === "artifact") {
3070
+ return this.#readArtifactFile(urlMeta, parsedSel, signal);
3071
+ }
2832
3072
 
2833
3073
  // local:// files are real on-disk paths. Detect image files and emit a
2834
3074
  // decoded image block before the text-only resource contract UTF-8
@@ -43,26 +43,6 @@ export type ToolRenderer = {
43
43
  mergeCallAndResult?: boolean;
44
44
  /** Render without background box, inline in the response flow */
45
45
  inline?: boolean;
46
- /**
47
- * Whether pending-call rows are provisional: useful on screen while a tool is
48
- * streaming, but not durable transcript history. `true` means every pending
49
- * shape is provisional. `"collapsed"` means only the collapsed pending shape
50
- * is provisional; expanded rendering is top-anchored/append-shaped enough to
51
- * let the transcript commit its settled prefix. Absent = the pending preview
52
- * streams rows the result render preserves.
53
- */
54
- provisionalPendingPreview?: boolean | "collapsed";
55
- /**
56
- * Whether the partial-result render is provisional: chrome rows (header
57
- * glyph, frame state) that change between `options.isPartial === true` and
58
- * the final result render. When `true`, the block is treated as
59
- * commit-unstable while a partial result is in flight, so the
60
- * stable-prefix ratchet in `deriveLiveCommitState` cannot promote the
61
- * partial chrome to native scrollback only to have the final render strand
62
- * it above the settled frame. Absent = the partial render is byte-stable
63
- * with the final render and may commit like any settled stream.
64
- */
65
- provisionalPartialResult?: boolean;
66
46
  /**
67
47
  * Whether the renderer's pending-call path visibly consumes
68
48
  * `options.spinnerFrame`. Used to avoid scheduling repaint ticks for live
package/src/tools/ssh.ts CHANGED
@@ -386,22 +386,6 @@ export const sshToolRenderer = {
386
386
  });
387
387
  },
388
388
  mergeCallAndResult: true,
389
- // Pending call preview can re-anchor wholesale when the final result inserts
390
- // the `Output` section, so no pending SSH rows may commit to native
391
- // scrollback — even when expanded. The expanded pending shape was previously
392
- // allowed to commit, which left two visible shapes in native scrollback once
393
- // the result settled: a stale `⏳ SSH: [host]` header above the final frame,
394
- // and the pending `╰──╯` footer reused in-place as the new `├── Output ──┤`
395
- // separator with a fresh footer pushed below it.
396
- provisionalPendingPreview: true,
397
- // Partial-result chrome (pending icon and frame state) differs from the
398
- // final SSH glyph/state, so the block stays commit-unstable while
399
- // `options.isPartial` holds. Without this, a long-running SSH command's
400
- // stable pending header would be promoted by the stable-prefix ratchet and
401
- // committed to native scrollback, then the final render's SSH glyph would
402
- // land below and strand a duplicate pending header above the final frame
403
- // ([#3177](https://github.com/can1357/oh-my-pi/issues/3177)).
404
- provisionalPartialResult: true,
405
389
  // Streamed args can initially render the SSH placeholder (`⏳ SSH: […]` /
406
390
  // `$ …`), then the first partial result inserts the `Output` section and
407
391
  // re-anchors the frame. Force a full repaint at that seam so placeholder rows
@@ -974,7 +974,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
974
974
  interface WriteRenderArgs {
975
975
  path?: string;
976
976
  file_path?: string;
977
- content?: string;
977
+ content?: unknown;
978
978
  }
979
979
 
980
980
  const WRITE_PREVIEW_LINES = 6;
@@ -990,8 +990,14 @@ function formatLineCountSuffix(lineCount: number, uiTheme: Theme): string {
990
990
  return uiTheme.fg("dim", ` · ${lineCount} line${lineCount === 1 ? "" : "s"}`);
991
991
  }
992
992
 
993
- function normalizeDisplayText(text: string): string {
994
- return text.replace(/\r/g, "");
993
+ function normalizeDisplayText(text: unknown): string {
994
+ let displayText = "";
995
+ if (typeof text === "string") {
996
+ displayText = text;
997
+ } else if (text !== undefined && text !== null) {
998
+ displayText = String(text);
999
+ }
1000
+ return displayText.replace(/\r/g, "");
995
1001
  }
996
1002
 
997
1003
  /**
@@ -1098,11 +1104,12 @@ export const writeToolRenderer = {
1098
1104
  },
1099
1105
  uiTheme,
1100
1106
  );
1107
+ const content = normalizeDisplayText(args.content);
1101
1108
  const streamingCache = createRenderedStringCache();
1102
1109
  return framedBlock(uiTheme, width => {
1103
- const body = args.content
1110
+ const body = content
1104
1111
  ? formatStreamingContent(
1105
- args.content,
1112
+ content,
1106
1113
  Boolean(options?.expanded),
1107
1114
  lang,
1108
1115
  uiTheme,
@@ -1130,7 +1137,7 @@ export const writeToolRenderer = {
1130
1137
  ): Component {
1131
1138
  const rawPath = args?.file_path || args?.path || "";
1132
1139
  const filePath = shortenPath(rawPath);
1133
- const fileContent = args?.content || "";
1140
+ const fileContent = normalizeDisplayText(args?.content);
1134
1141
  const lang = getLanguageFromPath(rawPath);
1135
1142
  const langIcon = uiTheme.fg("muted", uiTheme.getLangIcon(lang));
1136
1143
  // The header shows the cwd-relative path but links to the absolute path the
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
  }