@oh-my-pi/pi-coding-agent 17.3.3 → 17.3.4

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 (38) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/{CHANGELOG-s5t1xdmy.md → CHANGELOG-trcc215s.md} +13 -0
  3. package/dist/cli.js +3563 -3537
  4. package/dist/docs-index.generated.txt +1 -1
  5. package/dist/types/markit/converters/pdf/index.d.ts +2 -1
  6. package/dist/types/tools/read-pdf.d.ts +15 -0
  7. package/dist/types/utils/external-editor.d.ts +7 -0
  8. package/dist/types/utils/markit.d.ts +3 -4
  9. package/package.json +13 -16
  10. package/scripts/build-binary.ts +0 -2
  11. package/scripts/bundle-dist.ts +0 -1
  12. package/src/cli/read-cli.ts +2 -0
  13. package/src/markit/NOTICE +8 -8
  14. package/src/markit/converters/pdf/index.ts +14 -126
  15. package/src/mcp/client.ts +8 -9
  16. package/src/modes/rpc/rpc-client.ts +7 -0
  17. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  18. package/src/tools/read-pdf.ts +135 -0
  19. package/src/tools/read.ts +63 -37
  20. package/src/utils/external-editor.ts +24 -5
  21. package/src/utils/markit.ts +6 -44
  22. package/dist/types/markit/converters/pdf/columns.d.ts +0 -35
  23. package/dist/types/markit/converters/pdf/extract.d.ts +0 -10
  24. package/dist/types/markit/converters/pdf/grid.d.ts +0 -25
  25. package/dist/types/markit/converters/pdf/headers.d.ts +0 -24
  26. package/dist/types/markit/converters/pdf/render.d.ts +0 -24
  27. package/dist/types/markit/converters/pdf/types.d.ts +0 -75
  28. package/dist/types/tools/read-pdf-images.d.ts +0 -12
  29. package/dist/types/utils/mupdf-wasm-embed.d.ts +0 -1
  30. package/scripts/embed-mupdf-wasm.ts +0 -67
  31. package/src/markit/converters/pdf/columns.ts +0 -103
  32. package/src/markit/converters/pdf/extract.ts +0 -598
  33. package/src/markit/converters/pdf/grid.ts +0 -780
  34. package/src/markit/converters/pdf/headers.ts +0 -106
  35. package/src/markit/converters/pdf/render.ts +0 -501
  36. package/src/markit/converters/pdf/types.ts +0 -84
  37. package/src/tools/read-pdf-images.ts +0 -250
  38. package/src/utils/mupdf-wasm-embed.ts +0 -12
@@ -1,6 +1,7 @@
1
1
  import type { ConversionResult, Converter, StreamInfo } from "../../types.js";
2
+ /** Converts PDF buffers to Markdown through the native `pdf-inspector` bridge. */
2
3
  export declare class PdfConverter implements Converter {
3
4
  name: string;
4
5
  accepts(streamInfo: StreamInfo): boolean;
5
- convert(input: Buffer, streamInfo: StreamInfo): Promise<ConversionResult>;
6
+ convert(input: Buffer, _streamInfo: StreamInfo): Promise<ConversionResult>;
6
7
  }
@@ -0,0 +1,15 @@
1
+ import type { ToolSession } from "../sdk.js";
2
+ import type { ScreenshotResult } from "./browser/tab-protocol.js";
3
+ /** A legacy PDF image-member path interpreted as a page screenshot request. */
4
+ export interface PdfImageReadTarget {
5
+ /** PDF path before the member delimiter. */
6
+ pdfPath: string;
7
+ /** Original member text after the delimiter. */
8
+ member: string;
9
+ /** One-indexed page inferred from names such as `p2-img0.png`; defaults to page 1. */
10
+ page: number;
11
+ }
12
+ /** Parse a former PDF image-member path as a Chromium page screenshot request. */
13
+ export declare function splitPdfImageReadPath(readPath: string): PdfImageReadTarget | null;
14
+ /** Render one PDF page through the browser tool's shared headless Chromium. */
15
+ export declare function renderPdfPageScreenshot(session: ToolSession, absolutePdfPath: string, page: number, signal?: AbortSignal): Promise<ScreenshotResult>;
@@ -18,6 +18,13 @@ export interface OpenInEditorOptions {
18
18
  /** Keep the file's trailing newline instead of trimming it from the returned text. */
19
19
  trimTrailingNewline?: boolean;
20
20
  }
21
+ /** Subprocess argv and Windows quoting mode used to launch an external editor. */
22
+ export interface EditorSpawnCommand {
23
+ cmd: string[];
24
+ windowsVerbatimArguments: boolean;
25
+ }
26
+ /** Resolves shell argv without letting the host runtime re-quote the editor command. */
27
+ export declare function resolveEditorSpawnCommand(editorCmd: string, tmpFile: string, platform?: NodeJS.Platform): EditorSpawnCommand;
21
28
  /**
22
29
  * Opens `content` in the user's external editor and returns the edited text.
23
30
  * Returns `null` if the editor exits with a non-zero code.
@@ -18,10 +18,9 @@ export interface MarkitConversionResult {
18
18
  }
19
19
  export interface MarkitFileConversionOptions {
20
20
  /**
21
- * Directory the PDF converter writes extracted images/diagrams into. When
22
- * set, each embedded image is rendered to `<id>.png` and referenced by path
23
- * in the markdown; when unset, markit emits an `<!-- image: <id> ... -->`
24
- * placeholder comment instead.
21
+ * Directory converters may use for extracted image or diagram files. Since
22
+ * those files are conversion side effects, conversions using this option
23
+ * bypass the markdown cache.
25
24
  */
26
25
  imageDir?: string;
27
26
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "17.3.3",
4
+ "version": "17.3.4",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -41,8 +41,6 @@
41
41
  "format-prompts": "bun scripts/format-prompts.ts",
42
42
  "gen:tool-views": "bun --cwd=../collab-web run gen:tool-views",
43
43
  "gen:bundle": "bun scripts/bundle-dist.ts",
44
- "gen:mupdf": "bun scripts/embed-mupdf-wasm.ts --generate",
45
- "gen:mupdf:reset": "bun scripts/embed-mupdf-wasm.ts --reset",
46
44
  "gen:native": "bun --cwd=../natives run gen:native",
47
45
  "gen:native:reset": "bun --cwd=../natives run gen:native:reset",
48
46
  "prepack": "bun run gen:tool-views && bun run gen:bundle",
@@ -50,18 +48,18 @@
50
48
  },
51
49
  "dependencies": {
52
50
  "@babel/parser": "^7.29.7",
53
- "@oh-my-pi/hashline": "17.3.3",
54
- "@oh-my-pi/omp-stats": "17.3.3",
55
- "@oh-my-pi/omptype": "17.3.3",
56
- "@oh-my-pi/pi-agent-core": "17.3.3",
57
- "@oh-my-pi/pi-ai": "17.3.3",
58
- "@oh-my-pi/pi-catalog": "17.3.3",
59
- "@oh-my-pi/pi-mnemopi": "17.3.3",
60
- "@oh-my-pi/pi-natives": "17.3.3",
61
- "@oh-my-pi/pi-tui": "17.3.3",
62
- "@oh-my-pi/pi-utils": "17.3.3",
63
- "@oh-my-pi/pi-wire": "17.3.3",
64
- "@oh-my-pi/snapcompact": "17.3.3",
51
+ "@oh-my-pi/hashline": "17.3.4",
52
+ "@oh-my-pi/omp-stats": "17.3.4",
53
+ "@oh-my-pi/omptype": "17.3.4",
54
+ "@oh-my-pi/pi-agent-core": "17.3.4",
55
+ "@oh-my-pi/pi-ai": "17.3.4",
56
+ "@oh-my-pi/pi-catalog": "17.3.4",
57
+ "@oh-my-pi/pi-mnemopi": "17.3.4",
58
+ "@oh-my-pi/pi-natives": "17.3.4",
59
+ "@oh-my-pi/pi-tui": "17.3.4",
60
+ "@oh-my-pi/pi-utils": "17.3.4",
61
+ "@oh-my-pi/pi-wire": "17.3.4",
62
+ "@oh-my-pi/snapcompact": "17.3.4",
65
63
  "@opentelemetry/api": "^1.9.1",
66
64
  "@opentelemetry/api-logs": "^0.220.0",
67
65
  "@opentelemetry/context-async-hooks": "^2.9.0",
@@ -73,7 +71,6 @@
73
71
  "@opentelemetry/sdk-metrics": "^2.9.0",
74
72
  "@opentelemetry/sdk-trace-base": "^2.9.0",
75
73
  "@opentelemetry/sdk-trace-node": "^2.9.0",
76
- "mupdf": "^1.28.0",
77
74
  "puppeteer-core": "25.3.0"
78
75
  },
79
76
  "optionalDependencies": {
@@ -88,7 +88,6 @@ async function main(): Promise<void> {
88
88
  ["bun", "--cwd=../natives", "run", "gen:native"],
89
89
  crossBuild ? { ...Bun.env, TARGET_PLATFORM: crossBuild.platform, TARGET_ARCH: crossBuild.arch } : Bun.env,
90
90
  );
91
- await runCommand(["bun", "run", "gen:mupdf"]);
92
91
  try {
93
92
  await compileCodingAgent({
94
93
  repoRoot,
@@ -104,7 +103,6 @@ async function main(): Promise<void> {
104
103
  await runCommand(["codesign", "--force", "--sign", "-", outputPath]);
105
104
  }
106
105
  } finally {
107
- await runCommand(["bun", "run", "gen:mupdf:reset"]);
108
106
  await runCommand(["bun", "--cwd=../natives", "run", "gen:native:reset"]);
109
107
  }
110
108
  } finally {
@@ -15,7 +15,6 @@ const legacyHtmlExportAssetPattern = /^(?:template-[^.]+\.(?:css|html|js)|tool-v
15
15
  // `omp-legacy-pi-modules` exists only in compiled binaries via the build plugin;
16
16
  // the npm bundle never executes that `isCompiledBinary()` branch.
17
17
  const ALWAYS_EXTERNAL = [
18
- "mupdf",
19
18
  "@oh-my-pi/pi-natives",
20
19
  "@huggingface/transformers",
21
20
  "fastembed",
@@ -10,6 +10,7 @@ import chalk from "@oh-my-pi/pi-utils/chalk";
10
10
  import { Settings } from "../config/settings";
11
11
  import { extractUriScheme } from "../internal-urls/parse";
12
12
  import { InternalUrlRouter } from "../internal-urls/router";
13
+ import { closeDaemonClients } from "../launch/client";
13
14
  import { discoverAndLoadMCPTools } from "../mcp/loader";
14
15
  import { MCPManager } from "../mcp/manager";
15
16
  import { discoverAuthStorage } from "../session/auth-broker-config";
@@ -93,6 +94,7 @@ export async function runReadCommand(cmd: ReadCommandArgs): Promise<void> {
93
94
  if (MCPManager.instance() === mcpManager) MCPManager.setInstance(undefined);
94
95
  }
95
96
  authStorage?.close();
97
+ await closeDaemonClients();
96
98
  }
97
99
 
98
100
  if (failed) process.exit(1);
package/src/markit/NOTICE CHANGED
@@ -1,15 +1,15 @@
1
- This directory contains an in-house document-to-markdown engine adapted from
1
+ Portions of this in-house document-to-markdown engine are adapted from
2
2
  markit-ai (https://github.com/Michaelliv/markit), used under the MIT License.
3
+ This attribution covers the shared registry/types and the DOCX, PPTX, XLSX,
4
+ and EPUB converters. The PDF converter is implemented separately and is not
5
+ derived from markit-ai.
3
6
 
4
7
  Copyright (c) 2026 Michael Liv
5
8
 
6
- Only the converters for the document formats omp supports are ported (pdf,
7
- docx, pptx, xlsx, epub); the CLI, plugin/provider, and unused converters
8
- (html, image, audio, plain-text, rss, github, wikipedia, csv, json, yaml,
9
- ipynb, iwork, zip, xml) were dropped. Legacy binary `.doc`/`.ppt`/`.xls` and
10
- `.rtf` are routed by the read/fetch tools but have no converter — they surface
11
- a conversion error, exactly as upstream markit did. Logic is ported faithfully
12
- so conversion output matches the upstream package.
9
+ The CLI, plugin/provider, and unused converters (html, image, audio,
10
+ plain-text, rss, github, wikipedia, csv, json, yaml, ipynb, iwork, zip, xml)
11
+ were dropped. Legacy binary `.doc`/`.ppt`/`.xls` and `.rtf` have no converter
12
+ and surface a conversion error.
13
13
 
14
14
  MIT License
15
15
 
@@ -1,48 +1,10 @@
1
- // Adapted from markit-ai (MIT). See ../../NOTICE.
2
-
3
- /**
4
- * PDF to Markdown converter.
5
- *
6
- * Uses mupdf (native WASM) for fast PDF parsing and a custom pipeline for
7
- * table detection via vector line extraction + raycasting.
8
- *
9
- * Pipeline:
10
- * 1. Extract text boxes + vector segments + image regions per page (mupdf)
11
- * 2. Detect column layout (single vs multi-column)
12
- * 3. Per column: detect table grids from segments (grid detection + raycasting)
13
- * 4. Render diagrams as PNG files (if output directory provided)
14
- * 5. Render tables as markdown tables, free text as paragraphs/headings
15
- */
16
- import * as path from "node:path";
1
+ import { pdfToMarkdown } from "@oh-my-pi/pi-natives";
17
2
  import type { ConversionResult, Converter, StreamInfo } from "../../types";
18
- import { detectColumns } from "./columns";
19
- import { extractPages, renderImageRegion } from "./extract";
20
- import { resolveTableGrids } from "./grid";
21
- import { stripHeadersFooters } from "./headers";
22
- import { renderPageContent } from "./render";
23
- import type { Segment, TextBox } from "./types";
24
3
 
25
4
  const EXTENSIONS = [".pdf"];
26
5
  const MIMETYPES = ["application/pdf", "application/x-pdf"];
27
6
 
28
- type ImageBlock = { topY: number; markdown: string };
29
-
30
- /**
31
- * Process a set of text boxes (one column or full page): run table detection,
32
- * separate free text, and render to markdown.
33
- */
34
- function processColumn(
35
- pageNumber: number,
36
- textBoxes: TextBox[],
37
- segments: Segment[],
38
- imageBlocks: ImageBlock[],
39
- ): string {
40
- const { grids, consumedIds } = resolveTableGrids(pageNumber, textBoxes, segments);
41
- const consumedSet = new Set(consumedIds);
42
- const freeTextBoxes = textBoxes.filter(tb => !consumedSet.has(tb.id));
43
- return renderPageContent(freeTextBoxes, grids, imageBlocks, textBoxes);
44
- }
45
-
7
+ /** Converts PDF buffers to Markdown through the native `pdf-inspector` bridge. */
46
8
  export class PdfConverter implements Converter {
47
9
  name = "pdf";
48
10
 
@@ -56,91 +18,17 @@ export class PdfConverter implements Converter {
56
18
  return false;
57
19
  }
58
20
 
59
- async convert(input: Buffer, streamInfo: StreamInfo): Promise<ConversionResult> {
60
- const pdfBytes = new Uint8Array(input);
61
- const pages = await extractPages(pdfBytes);
62
- // Remove running headers/footers before processing.
63
- stripHeadersFooters(pages);
64
- const imageDir = streamInfo.imageDir;
65
-
66
- const pageMarkdowns: string[] = [];
67
- for (const page of pages) {
68
- // Build image blocks for this page.
69
- const imageBlocks: ImageBlock[] = [];
70
- if (imageDir && page.images.length > 0) {
71
- for (const img of page.images) {
72
- const filename = `${img.id}.png`;
73
- const filepath = path.join(imageDir, filename);
74
- try {
75
- const png = await renderImageRegion(pdfBytes, img);
76
- await Bun.write(filepath, png);
77
- imageBlocks.push({ topY: img.topY, markdown: `![${img.id}](${filepath})` });
78
- } catch {
79
- // Image rendering failed — skip.
80
- }
81
- }
82
- } else if (page.images.length > 0) {
83
- for (const img of page.images) {
84
- imageBlocks.push({
85
- topY: img.topY,
86
- markdown: `<!-- image: ${img.id} (page ${img.pageNumber}, ${img.bbox.w}x${img.bbox.h}pt) -->`,
87
- });
88
- }
89
- }
90
-
91
- // Detect column layout.
92
- // If the page has vertical segments (tables), suppress column detection
93
- // when one detected column is very narrow — that's a table's first column,
94
- // not a page layout column.
95
- const layout = detectColumns(page.textBoxes);
96
- if (layout.columnCount > 1 && page.segments.some(s => Math.abs(s.x1 - s.x2) <= 0.8)) {
97
- const pageXMin = Math.min(...page.textBoxes.map(tb => tb.bounds.left));
98
- const pageXMax = Math.max(...page.textBoxes.map(tb => tb.bounds.right));
99
- const pageWidth = pageXMax - pageXMin;
100
- const minColFraction = 0.3;
101
- const tooNarrow = layout.columns.some(col => {
102
- const colXMin = Math.min(...col.map(tb => tb.bounds.left));
103
- const colXMax = Math.max(...col.map(tb => tb.bounds.right));
104
- return (colXMax - colXMin) / pageWidth < minColFraction;
105
- });
106
- if (tooNarrow) {
107
- layout.columnCount = 1;
108
- layout.columns = [page.textBoxes];
109
- layout.boundaries = [];
110
- }
111
- }
112
-
113
- if (layout.columnCount === 1) {
114
- // Single column — process normally.
115
- const md = processColumn(page.pageNumber, page.textBoxes, page.segments, imageBlocks);
116
- if (md.length > 0) pageMarkdowns.push(md);
117
- } else {
118
- // Multi-column — process each column independently, then join.
119
- const columnMarkdowns: string[] = [];
120
- for (const colBoxes of layout.columns) {
121
- // Filter segments to those within this column's X range.
122
- const colXMin = Math.min(...colBoxes.map(tb => tb.bounds.left));
123
- const colXMax = Math.max(...colBoxes.map(tb => tb.bounds.right));
124
- const margin = 10;
125
- const colSegments = page.segments.filter(seg => {
126
- const segXMin = Math.min(seg.x1, seg.x2);
127
- const segXMax = Math.max(seg.x1, seg.x2);
128
- return segXMax >= colXMin - margin && segXMin <= colXMax + margin;
129
- });
130
- // Images go with the first column only (no X info to split by).
131
- const md = processColumn(
132
- page.pageNumber,
133
- colBoxes,
134
- colSegments,
135
- columnMarkdowns.length === 0 ? imageBlocks : [],
136
- );
137
- if (md.length > 0) columnMarkdowns.push(md);
138
- }
139
- const joined = columnMarkdowns.join("\n\n");
140
- if (joined.length > 0) pageMarkdowns.push(joined);
141
- }
142
- }
143
-
144
- return { markdown: pageMarkdowns.join("\n\n") };
21
+ async convert(input: Buffer, _streamInfo: StreamInfo): Promise<ConversionResult> {
22
+ const result = await pdfToMarkdown(input);
23
+ const notice =
24
+ result.pagesNeedingOcr.length > 0
25
+ ? `Text extraction is incomplete for PDF pages ${result.pagesNeedingOcr.join(", ")}. Use the browser tool to render those pages or OCR them.`
26
+ : undefined;
27
+
28
+ const conversion: ConversionResult = {
29
+ markdown: notice ? [result.markdown, notice].filter(Boolean).join("\n\n") : result.markdown,
30
+ };
31
+ if (result.title !== undefined) conversion.title = result.title;
32
+ return conversion;
145
33
  }
146
34
  }
package/src/mcp/client.ts CHANGED
@@ -92,7 +92,7 @@ async function initializeConnection(
92
92
  transport: MCPTransport,
93
93
  options?: {
94
94
  signal?: AbortSignal;
95
- /** Called after the initialize response (which sets the session ID) but before notifications/initialized. */
95
+ /** Called after notifications/initialized succeeds. */
96
96
  onInitialized?: () => void | Promise<void>;
97
97
  },
98
98
  ): Promise<MCPInitializeResult> {
@@ -119,14 +119,13 @@ async function initializeConnection(
119
119
  // initialize; transports that don't need it ignore this.
120
120
  transport.setProtocolVersion?.(result.protocolVersion);
121
121
 
122
- // Hook point: the transport now has the session ID from the initialize response.
123
- // For HTTP, this is the moment to open the SSE stream so server-to-client requests
124
- // triggered by notifications/initialized (e.g. roots/list) can be delivered.
125
- await options?.onInitialized?.();
126
-
127
- // Send initialized notification
122
+ // Send initialized before opening the optional GET SSE stream. Servers may
123
+ // reject or terminate sessions that receive session traffic before this
124
+ // notification; POST response streams already carry messages during setup.
128
125
  await transport.notify("notifications/initialized");
129
126
 
127
+ await options?.onInitialized?.();
128
+
130
129
  return result;
131
130
  }
132
131
 
@@ -162,8 +161,8 @@ export async function connectToServer(
162
161
  const initResult = await initializeConnection(transport, {
163
162
  signal: options?.signal,
164
163
  async onInitialized() {
165
- // Open the SSE stream before sending initialized, so server-to-client
166
- // requests triggered by on_initialized (e.g. roots/list) are delivered.
164
+ // Open the optional GET SSE stream only after the initialized
165
+ // notification makes the session ready for further traffic.
167
166
  if ("startSSEListener" in transport! && typeof transport!.startSSEListener === "function") {
168
167
  await (transport as { startSSEListener(): Promise<void> }).startSSEListener();
169
168
  }
@@ -353,6 +353,13 @@ export class RpcClient {
353
353
  // failures are reaped by the readyPromise catch below; established
354
354
  // workers are reaped here so pending requests cannot hang indefinitely.
355
355
  if (!readySettled) {
356
+ // Stdout can close before the exit reaper finishes draining stderr.
357
+ // child.exited settles only after the stderr tail is complete (for
358
+ // nonzero exits), so give it a bounded head start: the exit watcher
359
+ // below was registered first and rejects with the real stderr text
360
+ // instead of an empty "Stderr:" (flaked under full-suite load).
361
+ await Promise.race([child.exited.catch(() => {}), Bun.sleep(250)]);
362
+ if (readySettled) return;
356
363
  readySettled = true;
357
364
  readyReject(new Error(`Agent output stream ended before ready. Stderr: ${child.peekStderr()}`));
358
365
  return;
@@ -39,7 +39,7 @@ export function buildHotkeysMarkdown(bindings: HotkeysMarkdownBindings): string
39
39
  "| `Tab` | Path completion / accept autocomplete |",
40
40
  `| \`${appKey(bindings, "app.interrupt")}\` | Cancel autocomplete / interrupt active work |`,
41
41
  `| \`${appKey(bindings, "app.clear")}\` | Clear editor (first) / exit (second) |`,
42
- `| \`${appKey(bindings, "app.exit")}\` | Exit (when editor is empty) |`,
42
+ `| \`${appKey(bindings, "app.exit")}\` | Exit (saves current prompt as draft) |`,
43
43
  `| \`${appKey(bindings, "app.suspend")}\` | Suspend to background |`,
44
44
  `| \`${appKey(bindings, "app.display.reset")}\` | Reset terminal display |`,
45
45
  `| \`${appKey(bindings, "app.thinking.cycle")}\` | Cycle thinking level |`,
@@ -0,0 +1,135 @@
1
+ import { pathToFileURL } from "node:url";
2
+ import { untilAborted } from "@oh-my-pi/pi-utils";
3
+ import type { ToolSession } from "../sdk";
4
+ import type { BrowserHandle } from "./browser/registry";
5
+ import type { ScreenshotResult } from "./browser/tab-protocol";
6
+ import { ToolAbortError, ToolError } from "./tool-errors";
7
+
8
+ const PDF_IMAGE_MEMBER_RE = /^(.*\.pdf):(.*)$/i;
9
+ const PDF_PAGE_MEMBER_RE = /^(?:p|page[-_]?)(\d+)(?:[-_].*)?\.png$/i;
10
+ const PDF_RENDER_TIMEOUT_MS = 30_000;
11
+
12
+ // Chromium's PDF plugin paints in an out-of-process frame after navigation has
13
+ // completed. Wait for document dimensions, then cross compositor boundaries
14
+ // before capturing; otherwise the screenshot can contain only the viewer shell.
15
+ const PDF_SCREENSHOT_CODE = `
16
+ let viewerFrame;
17
+ await wait(async () => {
18
+ for (const frame of page.frames()) {
19
+ try {
20
+ const loaded = await frame.evaluate(() => {
21
+ const viewer = document.querySelector("pdf-viewer");
22
+ const toolbar = viewer?.shadowRoot?.querySelector("viewer-toolbar");
23
+ const pageLength = toolbar
24
+ ?.shadowRoot?.querySelector("viewer-page-selector")
25
+ ?.shadowRoot?.querySelector("#pagelength")
26
+ ?.textContent;
27
+ if (Number(pageLength) > 0 && !toolbar?.hasAttribute("loading_")) return true;
28
+
29
+ const plugin = document.querySelector('embed[type="application/x-google-chrome-pdf"]');
30
+ const sizer = document.querySelector("#sizer");
31
+ return plugin !== null && sizer !== null && sizer.clientWidth > 0 && sizer.clientHeight > 0;
32
+ });
33
+ if (loaded) {
34
+ viewerFrame = frame;
35
+ return true;
36
+ }
37
+ } catch {}
38
+ }
39
+ return false;
40
+ });
41
+ await page.screenshot({ type: "png" });
42
+ await viewerFrame.evaluate(() => {
43
+ const { promise, resolve } = Promise.withResolvers();
44
+ requestAnimationFrame(() =>
45
+ requestAnimationFrame(() =>
46
+ requestAnimationFrame(() => requestAnimationFrame(resolve)),
47
+ ),
48
+ );
49
+ return promise;
50
+ });
51
+ return await tab.screenshot({ fullPage: true, silent: true });
52
+ `;
53
+
54
+ /** A legacy PDF image-member path interpreted as a page screenshot request. */
55
+ export interface PdfImageReadTarget {
56
+ /** PDF path before the member delimiter. */
57
+ pdfPath: string;
58
+ /** Original member text after the delimiter. */
59
+ member: string;
60
+ /** One-indexed page inferred from names such as `p2-img0.png`; defaults to page 1. */
61
+ page: number;
62
+ }
63
+
64
+ /** Parse a former PDF image-member path as a Chromium page screenshot request. */
65
+ export function splitPdfImageReadPath(readPath: string): PdfImageReadTarget | null {
66
+ const match = PDF_IMAGE_MEMBER_RE.exec(readPath);
67
+ const pdfPath = match?.[1];
68
+ const member = match?.[2];
69
+ if (!pdfPath || member === undefined) return null;
70
+ const pageText = PDF_PAGE_MEMBER_RE.exec(member)?.[1];
71
+ const parsedPage = pageText === undefined ? 1 : Number(pageText);
72
+ const page = Number.isSafeInteger(parsedPage) && parsedPage > 0 ? parsedPage : 1;
73
+ return { pdfPath, member, page };
74
+ }
75
+
76
+ /** Render one PDF page through the browser tool's shared headless Chromium. */
77
+ export async function renderPdfPageScreenshot(
78
+ session: ToolSession,
79
+ absolutePdfPath: string,
80
+ page: number,
81
+ signal?: AbortSignal,
82
+ ): Promise<ScreenshotResult> {
83
+ const [{ acquireBrowser, holdBrowser, releaseBrowser }, { acquireTab, releaseTab, runInTab }] = await Promise.all([
84
+ import("./browser/registry"),
85
+ import("./browser/tab-supervisor"),
86
+ ]);
87
+ const timeoutSignal = AbortSignal.timeout(PDF_RENDER_TIMEOUT_MS);
88
+ const renderSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
89
+ const tabName = `read-pdf-${Bun.randomUUIDv7()}`;
90
+ const url = pathToFileURL(absolutePdfPath);
91
+ url.hash = `page=${page}&toolbar=0&navpanes=0&view=Fit`;
92
+
93
+ let browserLease = false;
94
+ let tabOpened = false;
95
+ let browser: BrowserHandle | undefined;
96
+ try {
97
+ const acquiredBrowser = await untilAborted(renderSignal, () =>
98
+ acquireBrowser({ kind: "headless", headless: true }, { cwd: session.cwd, signal: renderSignal }),
99
+ );
100
+ browser = acquiredBrowser;
101
+ holdBrowser(acquiredBrowser);
102
+ browserLease = true;
103
+ await untilAborted(renderSignal, () =>
104
+ acquireTab(tabName, acquiredBrowser, {
105
+ url: url.href,
106
+ waitUntil: "load",
107
+ timeoutMs: PDF_RENDER_TIMEOUT_MS,
108
+ signal: renderSignal,
109
+ ownerSessionId: session.getSessionId?.() ?? undefined,
110
+ }),
111
+ );
112
+ tabOpened = true;
113
+ await releaseBrowser(acquiredBrowser, { kill: false });
114
+ browserLease = false;
115
+
116
+ const result = await runInTab(tabName, {
117
+ code: PDF_SCREENSHOT_CODE,
118
+ timeoutMs: PDF_RENDER_TIMEOUT_MS,
119
+ signal: renderSignal,
120
+ session,
121
+ });
122
+ const screenshot = result.screenshots.at(-1);
123
+ if (!screenshot) throw new ToolError(`Chromium did not capture PDF page ${page}.`);
124
+ return screenshot;
125
+ } catch (error) {
126
+ if (signal?.aborted) throw new ToolAbortError();
127
+ if (timeoutSignal.aborted) {
128
+ throw new ToolError(`Timed out rendering PDF page ${page} in Chromium.`);
129
+ }
130
+ throw error;
131
+ } finally {
132
+ if (tabOpened) await releaseTab(tabName, { kill: false });
133
+ if (browserLease && browser) await releaseBrowser(browser, { kill: false });
134
+ }
135
+ }