@oh-my-pi/pi-coding-agent 17.3.3 → 17.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 (154) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/dist/{CHANGELOG-s5t1xdmy.md → CHANGELOG-tt9k4jpr.md} +81 -0
  3. package/dist/cli.js +3724 -3705
  4. package/dist/docs-index.generated.txt +1 -1
  5. package/dist/types/config/model-discovery.d.ts +2 -0
  6. package/dist/types/config/model-registry.d.ts +6 -1
  7. package/dist/types/config/settings-schema.d.ts +10 -0
  8. package/dist/types/debug/report-bundle.d.ts +7 -2
  9. package/dist/types/extensibility/extensions/loader.d.ts +2 -0
  10. package/dist/types/extensibility/extensions/runner.d.ts +3 -3
  11. package/dist/types/extensibility/extensions/types.d.ts +2 -0
  12. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +4 -0
  13. package/dist/types/launch/presence.d.ts +11 -0
  14. package/dist/types/lsp/client.d.ts +22 -0
  15. package/dist/types/lsp/workspace-diagnostics.d.ts +2 -0
  16. package/dist/types/markit/converters/pdf/index.d.ts +2 -1
  17. package/dist/types/mcp/config.d.ts +3 -1
  18. package/dist/types/modes/components/tool-execution.d.ts +3 -1
  19. package/dist/types/registry/agent-lifecycle.d.ts +11 -0
  20. package/dist/types/registry/agent-registry.d.ts +4 -0
  21. package/dist/types/session/agent-session-types.d.ts +0 -2
  22. package/dist/types/session/agent-session.d.ts +20 -0
  23. package/dist/types/session/date-cwd-reminder.d.ts +22 -0
  24. package/dist/types/session/irc-bridge.d.ts +2 -0
  25. package/dist/types/session/messages.d.ts +4 -0
  26. package/dist/types/session/session-loader.d.ts +18 -11
  27. package/dist/types/session/session-maintenance.d.ts +6 -2
  28. package/dist/types/session/session-manager.d.ts +19 -3
  29. package/dist/types/session/session-tools.d.ts +0 -1
  30. package/dist/types/session/turn-recovery.d.ts +7 -7
  31. package/dist/types/tools/browser/attach.d.ts +16 -0
  32. package/dist/types/tools/browser/launch.d.ts +2 -2
  33. package/dist/types/tools/browser.d.ts +1 -1
  34. package/dist/types/tools/builtin-names.d.ts +1 -1
  35. package/dist/types/tools/hub/jobs.d.ts +6 -0
  36. package/dist/types/tools/hub/types.d.ts +6 -0
  37. package/dist/types/tools/read-pdf.d.ts +15 -0
  38. package/dist/types/utils/external-editor.d.ts +7 -0
  39. package/dist/types/utils/markit.d.ts +3 -4
  40. package/dist/types/vibe/runtime.d.ts +3 -3
  41. package/package.json +13 -16
  42. package/scripts/build-binary.ts +0 -2
  43. package/scripts/bundle-dist.ts +0 -1
  44. package/src/auto-thinking/classifier.ts +37 -23
  45. package/src/cli/models-cli.ts +1 -1
  46. package/src/cli/read-cli.ts +2 -0
  47. package/src/commit/analysis/conventional.ts +15 -9
  48. package/src/commit/analysis/summary.ts +15 -9
  49. package/src/commit/changelog/generate.ts +15 -9
  50. package/src/commit/map-reduce/map-phase.ts +3 -19
  51. package/src/commit/map-reduce/reduce-phase.ts +15 -9
  52. package/src/config/model-discovery.ts +3 -3
  53. package/src/config/model-registry.ts +29 -16
  54. package/src/config/settings-schema.ts +13 -0
  55. package/src/debug/report-bundle.ts +25 -59
  56. package/src/discovery/claude-plugins.ts +2 -1
  57. package/src/discovery/claude.ts +4 -2
  58. package/src/discovery/helpers.ts +5 -0
  59. package/src/edit/renderer.ts +48 -19
  60. package/src/extensibility/extensions/loader.ts +9 -5
  61. package/src/extensibility/extensions/runner.ts +124 -21
  62. package/src/extensibility/extensions/types.ts +2 -0
  63. package/src/extensibility/extensions/wrapper.ts +16 -10
  64. package/src/extensibility/legacy-pi-ai-shim.ts +4 -0
  65. package/src/extensibility/plugins/marketplace/manager.ts +15 -18
  66. package/src/irc/bus.ts +1 -1
  67. package/src/launch/broker.ts +8 -1
  68. package/src/launch/presence.ts +77 -1
  69. package/src/lsp/client.ts +35 -3
  70. package/src/lsp/clients/biome-client.ts +47 -93
  71. package/src/lsp/servers.ts +17 -11
  72. package/src/lsp/tool.ts +2 -4
  73. package/src/lsp/workspace-diagnostics.ts +24 -2
  74. package/src/markit/NOTICE +8 -8
  75. package/src/markit/converters/pdf/index.ts +14 -126
  76. package/src/mcp/client.ts +8 -9
  77. package/src/mcp/config.ts +50 -6
  78. package/src/memories/index.ts +29 -25
  79. package/src/mnemopi/backend.ts +13 -11
  80. package/src/modes/components/ask-dialog.ts +25 -5
  81. package/src/modes/components/tool-execution.ts +10 -6
  82. package/src/modes/components/welcome.ts +4 -1
  83. package/src/modes/controllers/extension-ui-controller.ts +52 -32
  84. package/src/modes/controllers/selector-controller.ts +6 -0
  85. package/src/modes/controllers/tan-command-controller.ts +1 -0
  86. package/src/modes/interactive-mode.ts +49 -10
  87. package/src/modes/rpc/rpc-client.ts +7 -0
  88. package/src/modes/theme/defaults/birch.json +2 -2
  89. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  90. package/src/prompts/system/checkpoint-active-notice.md +5 -0
  91. package/src/prompts/system/date-cwd-reminder.md +3 -0
  92. package/src/prompts/system/project-prompt.md +0 -1
  93. package/src/prompts/system/rewind-report.md +1 -3
  94. package/src/prompts/tools/browser.md +1 -0
  95. package/src/prompts/tools/rewind.md +1 -13
  96. package/src/registry/agent-lifecycle.ts +34 -0
  97. package/src/registry/agent-registry.ts +27 -2
  98. package/src/registry/persisted-agents.ts +40 -20
  99. package/src/sdk.ts +32 -2
  100. package/src/session/agent-session-types.ts +0 -2
  101. package/src/session/agent-session.ts +178 -46
  102. package/src/session/date-cwd-reminder.ts +77 -0
  103. package/src/session/irc-bridge.ts +5 -0
  104. package/src/session/messages.ts +5 -1
  105. package/src/session/session-loader.ts +106 -64
  106. package/src/session/session-maintenance.ts +189 -115
  107. package/src/session/session-manager.ts +142 -35
  108. package/src/session/session-persistence.ts +4 -4
  109. package/src/session/session-storage.ts +18 -5
  110. package/src/session/session-tools.ts +5 -10
  111. package/src/session/turn-recovery.ts +102 -20
  112. package/src/session/unexpected-stop-classifier.ts +33 -21
  113. package/src/system-prompt.ts +0 -5
  114. package/src/task/executor.ts +4 -15
  115. package/src/task/persisted-revive.ts +3 -6
  116. package/src/tools/ask.ts +10 -3
  117. package/src/tools/browser/attach.ts +80 -25
  118. package/src/tools/browser/launch.ts +44 -15
  119. package/src/tools/browser/relay/daemon.ts +3 -7
  120. package/src/tools/browser/render.ts +1 -1
  121. package/src/tools/browser/shared-daemon.ts +3 -7
  122. package/src/tools/browser.ts +5 -5
  123. package/src/tools/builtin-names.ts +7 -3
  124. package/src/tools/checkpoint.ts +1 -7
  125. package/src/tools/hub/index.ts +1 -1
  126. package/src/tools/hub/jobs.ts +20 -3
  127. package/src/tools/hub/types.ts +6 -0
  128. package/src/tools/read-pdf.ts +135 -0
  129. package/src/tools/read.ts +63 -37
  130. package/src/tools/render-utils.ts +53 -21
  131. package/src/tools/think.ts +15 -3
  132. package/src/tts/speech-enhancer.ts +19 -14
  133. package/src/utils/commit-message-generator.ts +14 -12
  134. package/src/utils/external-editor.ts +24 -5
  135. package/src/utils/markit.ts +6 -44
  136. package/src/utils/title-generator.ts +23 -19
  137. package/src/vibe/runtime.ts +32 -17
  138. package/dist/types/markit/converters/pdf/columns.d.ts +0 -35
  139. package/dist/types/markit/converters/pdf/extract.d.ts +0 -10
  140. package/dist/types/markit/converters/pdf/grid.d.ts +0 -25
  141. package/dist/types/markit/converters/pdf/headers.d.ts +0 -24
  142. package/dist/types/markit/converters/pdf/render.d.ts +0 -24
  143. package/dist/types/markit/converters/pdf/types.d.ts +0 -75
  144. package/dist/types/tools/read-pdf-images.d.ts +0 -12
  145. package/dist/types/utils/mupdf-wasm-embed.d.ts +0 -1
  146. package/scripts/embed-mupdf-wasm.ts +0 -67
  147. package/src/markit/converters/pdf/columns.ts +0 -103
  148. package/src/markit/converters/pdf/extract.ts +0 -598
  149. package/src/markit/converters/pdf/grid.ts +0 -780
  150. package/src/markit/converters/pdf/headers.ts +0 -106
  151. package/src/markit/converters/pdf/render.ts +0 -501
  152. package/src/markit/converters/pdf/types.ts +0 -84
  153. package/src/tools/read-pdf-images.ts +0 -250
  154. package/src/utils/mupdf-wasm-embed.ts +0 -12
@@ -17,6 +17,7 @@ import { daemonClientForGlobal } from "../../../launch/client";
17
17
  import { describeQuietly, stopQuietly, waitReady } from "../../../launch/ensure";
18
18
  import { resolveWorkerSpawnCmd } from "../../../subprocess/worker-client";
19
19
  import { throwIfAborted } from "../../tool-errors";
20
+ import { probeCdpStatus } from "../attach";
20
21
 
21
22
  /** Stable broker daemon name for the relay server. */
22
23
  export const RELAY_DAEMON_NAME = "omp.browser.relay";
@@ -30,13 +31,8 @@ const ENSURE_ATTEMPTS = 3;
30
31
 
31
32
  /** True when the relay HTTP server answers /json/version at all (200 = extension connected, 503 = waiting for it). */
32
33
  export async function probeRelayServer(cdpUrl: string): Promise<boolean> {
33
- try {
34
- const res = await fetch(`${cdpUrl}/json/version`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
35
- await res.body?.cancel();
36
- return res.ok || res.status === 503;
37
- } catch {
38
- return false;
39
- }
34
+ const status = await probeCdpStatus(`${cdpUrl}/json/version`, { timeoutMs: PROBE_TIMEOUT_MS });
35
+ return status === 503 || (status !== null && status >= 200 && status < 300);
40
36
  }
41
37
 
42
38
  /** Auto-start is only safe for endpoints this machine can own. */
@@ -162,7 +162,7 @@ function renderOpenOrCloseLine(
162
162
  let title: string;
163
163
  if (action === "close") {
164
164
  const all = args.all === true || (args.name === undefined && details?.name === undefined);
165
- title = all ? "Close all tabs" : `Close ${tabLabel(args, details)}`;
165
+ title = all ? "Release all tabs" : `Release ${tabLabel(args, details)}`;
166
166
  if (args.kill) title += " (kill)";
167
167
  } else {
168
168
  title = `Open ${tabLabel(args, details)}`;
@@ -16,6 +16,7 @@ import { describeQuietly, stopQuietly, waitReady } from "../../launch/ensure";
16
16
  import { daemonRuntimeDir } from "../../launch/paths";
17
17
  import type { DaemonSnapshot } from "../../launch/protocol";
18
18
  import { throwIfAborted } from "../tool-errors";
19
+ import { probeCdpStatus } from "./attach";
19
20
  import { resolveSharedBrowserLaunchSpec } from "./launch";
20
21
 
21
22
  /** Chrome prints this on stderr once the CDP listener is up; the broker's ready probe captures the line. */
@@ -50,13 +51,8 @@ async function probeEndpoint(wsEndpoint: string): Promise<boolean> {
50
51
  } catch {
51
52
  return false;
52
53
  }
53
- try {
54
- const res = await fetch(`http://${host}/json/version`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
55
- await res.body?.cancel();
56
- return res.ok;
57
- } catch {
58
- return false;
59
- }
54
+ const status = await probeCdpStatus(`http://${host}/json/version`, { timeoutMs: PROBE_TIMEOUT_MS });
55
+ return status !== null && status >= 200 && status < 300;
60
56
  }
61
57
 
62
58
  /**
@@ -69,7 +69,7 @@ const browserSchema = type({
69
69
  "dialogs?": type("'accept' | 'dismiss'").describe("auto-handle dialogs"),
70
70
  "code?": type("string").describe("js body to run in tab"),
71
71
  "timeout?": type("number").describe("timeout in seconds"),
72
- "all?": type("boolean").describe("close every tab"),
72
+ "all?": type("boolean").describe("release every managed tab"),
73
73
  "kill?": type("boolean").describe("also kill spawned-app browsers"),
74
74
  });
75
75
 
@@ -133,7 +133,7 @@ function resolveBrowserKind(params: BrowserParams, session: ToolSession): Browse
133
133
  /**
134
134
  * Browser tool: stateful, multi-tab. Three actions:
135
135
  * - `open` → acquire/create a named tab on a browser kind (headless | spawned | connected) and optionally goto a url.
136
- * - `close` → release a named tab (or all tabs); dispose browser when refcount hits 0.
136
+ * - `close` → release a named tab handle (or all handles); attached/relay pages remain open, and spawned pages remain unless killed.
137
137
  * - `run` → execute JS code against an existing tab with `page`/`browser`/`tab` helpers in scope.
138
138
  */
139
139
  export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolDetails> {
@@ -204,7 +204,7 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
204
204
  },
205
205
  },
206
206
  {
207
- caption: "Close every tab and kill spawned-app processes",
207
+ caption: "Release every managed tab and kill spawned-app processes",
208
208
  call: { action: "close", all: true, kill: true },
209
209
  },
210
210
  ];
@@ -362,11 +362,11 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
362
362
  const kill = !!params.kill;
363
363
  if (params.all) {
364
364
  const count = await untilAborted(signal, () => releaseAllTabs({ kill, timeoutMs }));
365
- details.result = `Closed ${count} tab(s)`;
365
+ details.result = `Released ${count} managed tab${count === 1 ? "" : "s"}`;
366
366
  return toolResult(details).text(details.result).done();
367
367
  }
368
368
  const closed = await untilAborted(signal, () => releaseTab(name, { kill, timeoutMs }));
369
- details.result = closed ? `Closed tab ${JSON.stringify(name)}` : `No tab named ${JSON.stringify(name)}`;
369
+ details.result = closed ? `Released managed tab ${JSON.stringify(name)}` : `No tab named ${JSON.stringify(name)}`;
370
370
  return toolResult(details).text(details.result).done();
371
371
  }
372
372
 
@@ -41,10 +41,14 @@ const LEGACY_BUILTIN_TOOL_NAME_ALIASES: ReadonlyMap<string, BuiltinToolName> = n
41
41
  ["find", "glob"],
42
42
  ]);
43
43
 
44
- /** Return the canonical tool name for current and legacy built-in tool IDs. */
44
+ const CANONICAL_TOOL_NAMES: Record<string, true> = Object.fromEntries(
45
+ [...BUILTIN_TOOL_NAMES, ...HIDDEN_TOOL_NAMES].map(name => [name, true]),
46
+ );
47
+
48
+ /** Canonicalize built-in IDs and legacy aliases. Leave plugin names unchanged. */
45
49
  export function normalizeToolName(name: string): string {
46
- const normalized = name.toLowerCase();
47
- return LEGACY_BUILTIN_TOOL_NAME_ALIASES.get(normalized) ?? normalized;
50
+ const lower = name.toLowerCase();
51
+ return LEGACY_BUILTIN_TOOL_NAME_ALIASES.get(lower) ?? (Object.hasOwn(CANONICAL_TOOL_NAMES, lower) ? lower : name);
48
52
  }
49
53
 
50
54
  /** Normalize and deduplicate tool names while preserving first-seen order. */
@@ -81,13 +81,7 @@ export class CheckpointTool implements AgentTool<typeof checkpointSchema, Checkp
81
81
  }
82
82
  const startedAt = new Date().toISOString();
83
83
  return toolResult<CheckpointToolDetails>({ goal: params.goal, startedAt })
84
- .text(
85
- [
86
- "Checkpoint created.",
87
- `Goal: ${params.goal}`,
88
- "Run your investigation, then call rewind with a concise report.",
89
- ].join("\n"),
90
- )
84
+ .text([`Checkpoint: ${params.goal}`, "Finish exploration and formulate findings."].join("\n"))
91
85
  .done();
92
86
  }
93
87
  }
@@ -377,7 +377,7 @@ export class HubTool implements AgentTool<typeof hubSchema, HubDetails> {
377
377
  // instead of blocking a full message-timeout window.
378
378
  const hasRunningPeer = messaging.registry
379
379
  .listVisibleTo(messaging.senderId)
380
- .some(ref => ref.status === "running");
380
+ .some(ref => messaging.registry.isRunning(ref));
381
381
  if (!hasRunningPeer) return nothingToWaitForResult(this.session);
382
382
  }
383
383
  return executeMessageWait(messaging, { from, timeoutMs: params.timeoutMs }, signal);
@@ -87,6 +87,12 @@ export function visibleJobs(manager: AsyncJobManager, ids: string[], ownerId: st
87
87
  * that activity instead of implying the system is quiet. Existence is
88
88
  * already public via the peer roster, so listing ids here leaks nothing new;
89
89
  * job *control* stays owner-scoped.
90
+ *
91
+ * Reporting deliberately uses the claimed `status`, not the session-corroborated
92
+ * `registry.isRunning` used by the wait-sustaining gates: a ref that claims
93
+ * `running` with no live turn is exactly the stale entry an operator must see
94
+ * here to cancel it (#8634). Hiding it would match the badge count to nothing
95
+ * and remove the only discovery path for the id.
90
96
  */
91
97
  export function runningAgentsOutsideJobs(session: ToolSession): AgentActivitySnapshot[] {
92
98
  const registry = session.agentRegistry;
@@ -113,6 +119,7 @@ export function runningAgentsOutsideJobs(session: ToolSession): AgentActivitySna
113
119
  ...(ref.parentId ? { parentId: ref.parentId } : {}),
114
120
  ...(ref.activity ? { activity: ref.activity } : {}),
115
121
  ageMs: Math.max(0, now - ref.createdAt),
122
+ live: registry.isRunning(ref),
116
123
  });
117
124
  }
118
125
  return out;
@@ -124,9 +131,15 @@ function describeAgents(agents: AgentActivitySnapshot[]): string[] {
124
131
  for (const agent of agents) {
125
132
  const parent = agent.parentId ? ` (spawned by \`${agent.parentId}\`)` : "";
126
133
  const activity = agent.activity ? ` — ${agent.activity}` : "";
127
- lines.push(`- \`${agent.id}\`${parent}up ${formatDuration(agent.ageMs)}${activity}`);
134
+ const stale = agent.live ? "" : " no turn in flight (stale registration?)";
135
+ lines.push(`- \`${agent.id}\`${parent} — up ${formatDuration(agent.ageMs)}${activity}${stale}`);
128
136
  }
129
137
  lines.push("", "These agents have no job entry; message them via `hub` send, transcripts at `history://<id>`.");
138
+ if (agents.some(agent => !agent.live)) {
139
+ lines.push(
140
+ "An agent with no turn in flight cannot answer a message and never satisfies a bare `wait`; clear it with `hub` cancel.",
141
+ );
142
+ }
130
143
  return lines;
131
144
  }
132
145
 
@@ -690,8 +703,12 @@ export function jobsRenderResult(
690
703
  maxCollapsed: COLLAPSED_LIST_LIMIT,
691
704
  itemType: "agent",
692
705
  renderItem: agent => {
693
- const icon = formatStatusIcon("running", uiTheme, options.spinnerFrame);
694
- const badge = formatBadge("agent", "accent", uiTheme);
706
+ const icon = agent.live
707
+ ? formatStatusIcon("running", uiTheme, options.spinnerFrame)
708
+ : formatStatusIcon("warning", uiTheme);
709
+ const badge = agent.live
710
+ ? formatBadge("agent", "accent", uiTheme)
711
+ : formatBadge("agent · no turn", "warning", uiTheme);
695
712
  const gist = agent.activity
696
713
  ? ` ${uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(agent.activity), LABEL_MAX_WIDTH, Ellipsis.Unicode))}`
697
714
  : "";
@@ -74,6 +74,12 @@ export interface AgentActivitySnapshot {
74
74
  activity?: string;
75
75
  /** Time since the agent was registered. */
76
76
  ageMs: number;
77
+ /**
78
+ * Whether an attached session corroborates the `running` claim. False marks
79
+ * a ref that says `running` with no turn in flight — either a spawn still
80
+ * wiring up or a stale registration that `hub cancel <id>` clears (#8634).
81
+ */
82
+ live: boolean;
77
83
  }
78
84
 
79
85
  /** Result details for messaging and job ops; fields are disjoint per op. */
@@ -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
+ }
package/src/tools/read.ts CHANGED
@@ -100,7 +100,7 @@ import {
100
100
  isRemoteMountPath,
101
101
  type SuffixMatchCache,
102
102
  } from "./read-path-resolution";
103
- import { readPdfImageMember, rewritePdfImagePlaceholders, splitPdfImageMemberReadPath } from "./read-pdf-images";
103
+ import { type PdfImageReadTarget, renderPdfPageScreenshot, splitPdfImageReadPath } from "./read-pdf";
104
104
  import { isMultiRange, isRawSelector, type ParsedSelector, parseSel, selToOffsetLimit } from "./read-selector";
105
105
  import { readSqlite, resolveSqliteReadPath } from "./read-sqlite";
106
106
  import { isProseSummaryPath, renderSummary, routeReadThroughBridge, trySummarize } from "./read-summary";
@@ -398,8 +398,13 @@ type ReadParams = ReadToolInput;
398
398
  */
399
399
  export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
400
400
  readonly name = "read";
401
- readonly approval = (args: unknown): ToolTier =>
402
- pathTargetsSsh(String((args as { path?: unknown }).path ?? "")) ? "exec" : "read";
401
+ readonly approval = (args: unknown): ToolTier => {
402
+ let readPath = "";
403
+ if (args && typeof args === "object" && "path" in args) readPath = String(args.path ?? "");
404
+ if (pathTargetsSsh(readPath)) return "exec";
405
+ const target = splitPathAndSel(readPath);
406
+ return target.sel === undefined && splitPdfImageReadPath(readPath) ? "exec" : "read";
407
+ };
403
408
  readonly label = "Read";
404
409
  readonly loadMode = "essential";
405
410
  description: string;
@@ -546,6 +551,40 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
546
551
  return toolResult<ReadToolDetails>({ notes, displayReadTargets }).content(content).done();
547
552
  }
548
553
 
554
+ async #readPdfPageScreenshot(options: {
555
+ readPath: string;
556
+ absolutePdfPath: string;
557
+ page: number;
558
+ pdfFileSize: number;
559
+ suffixResolution?: { from: string; to: string };
560
+ signal?: AbortSignal;
561
+ }): Promise<AgentToolResult<ReadToolDetails>> {
562
+ const { readPath, absolutePdfPath, page, pdfFileSize, suffixResolution, signal } = options;
563
+ const screenshot = await renderPdfPageScreenshot(this.session, absolutePdfPath, page, signal);
564
+ const screenshotFile = Bun.file(screenshot.dest);
565
+ const screenshotMetadata = await readImageMetadata(screenshot.dest);
566
+ const loaded = await this.#loadImageContent({
567
+ readPath,
568
+ absolutePath: screenshot.dest,
569
+ mimeType: screenshot.mimeType,
570
+ imageMetadata: screenshotMetadata,
571
+ fileSize: screenshotFile.size,
572
+ });
573
+ if (suffixResolution) {
574
+ const firstText = loaded.content.find((entry): entry is TextContent => entry.type === "text");
575
+ if (firstText) firstText.text = prependSuffixResolutionNotice(firstText.text, suffixResolution);
576
+ }
577
+ const image = loaded.content.find((entry): entry is ImageContent => entry.type === "image");
578
+ const details: ReadToolDetails = {
579
+ ...loaded.details,
580
+ resolvedPath: absolutePdfPath,
581
+ contentType: image?.mimeType ?? screenshot.mimeType,
582
+ fileSize: pdfFileSize,
583
+ suffixResolution,
584
+ };
585
+ return toolResult(details).content(loaded.content).sourcePath(loaded.sourcePath).done();
586
+ }
587
+
549
588
  /**
550
589
  * Build content blocks for an on-disk image file: an `inspect_image`
551
590
  * metadata note when inspection is active, otherwise the decoded image
@@ -892,7 +931,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
892
931
 
893
932
  // Prefer a literal filesystem match over selector interpretation so real
894
933
  // POSIX filenames containing selector-looking suffixes win over structured
895
- // archive / sqlite / pdf-image dispatch. A selector promoted from local://
934
+ // archive / sqlite / unsupported PDF-image dispatch. A selector promoted from local://
896
935
  // remains separate so it cannot be mistaken for part of the resolved path.
897
936
  const literalSplit =
898
937
  promotedSelector === undefined
@@ -903,6 +942,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
903
942
  ? readPath.includes(":") && (await probeLiteralPathExists(readPath, this.session.cwd)) !== "missing"
904
943
  : literalSplit.sel === undefined && splitPathAndSel(readPath).sel !== undefined;
905
944
 
945
+ let pdfImageRead: PdfImageReadTarget | null = null;
946
+
906
947
  if (!rawPathIsLiteral) {
907
948
  const archivePath = await resolveArchiveReadPath(this.session, readPath, suffixCache, signal);
908
949
  if (archivePath) {
@@ -925,39 +966,14 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
925
966
  return readSqlite(sqlitePath, signal);
926
967
  }
927
968
 
928
- const pdfImageMemberPath = splitPdfImageMemberReadPath(readPath);
929
- if (pdfImageMemberPath) {
930
- let absolutePdfPath = resolveReadPath(pdfImageMemberPath.pdfPath, this.session.cwd);
931
- let suffixResolution: { from: string; to: string } | undefined;
932
- try {
933
- const stat = await Bun.file(absolutePdfPath).stat();
934
- if (stat.isDirectory())
935
- throw new ToolError(`Path '${pdfImageMemberPath.pdfPath}' is a directory, not a PDF file`);
936
- } catch (error) {
937
- if (!isNotFoundError(error) || isRemoteMountPath(absolutePdfPath)) throw error;
938
- const suffixMatch = await findSuffixMatchCached(
939
- this.session,
940
- suffixCache,
941
- pdfImageMemberPath.pdfPath,
942
- signal,
943
- );
944
- if (!suffixMatch) throw new ToolError(`Path '${pdfImageMemberPath.pdfPath}' not found`);
945
- absolutePdfPath = suffixMatch.absolutePath;
946
- suffixResolution = { from: pdfImageMemberPath.pdfPath, to: suffixMatch.displayPath };
947
- }
948
- return readPdfImageMember(
949
- this.session,
950
- this.#autoResizeImages,
951
- absolutePdfPath,
952
- pdfImageMemberPath.pdfPath,
953
- pdfImageMemberPath.member,
954
- suffixResolution,
955
- signal,
956
- );
957
- }
969
+ const pdfCandidate = literalSplit.sel === undefined ? splitPdfImageReadPath(readPath) : null;
970
+ pdfImageRead =
971
+ pdfCandidate && (await probeLiteralPathExists(readPath, this.session.cwd)) === "missing"
972
+ ? pdfCandidate
973
+ : null;
958
974
  }
959
975
 
960
- const localTarget = literalSplit;
976
+ const localTarget = pdfImageRead ? { path: pdfImageRead.pdfPath, sel: undefined } : literalSplit;
961
977
  const localReadPath = localTarget.path;
962
978
  const parsed = parseSel(localTarget.sel);
963
979
 
@@ -1036,6 +1052,17 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1036
1052
  return this.#readFileConflicts(absolutePath, suffixResolution, signal);
1037
1053
  }
1038
1054
 
1055
+ if (pdfImageRead) {
1056
+ return this.#readPdfPageScreenshot({
1057
+ readPath,
1058
+ absolutePdfPath: absolutePath,
1059
+ page: pdfImageRead.page,
1060
+ pdfFileSize: fileSize,
1061
+ suffixResolution,
1062
+ signal,
1063
+ });
1064
+ }
1065
+
1039
1066
  const imageMetadata = await readImageMetadata(absolutePath);
1040
1067
  const mimeType = imageMetadata?.mimeType;
1041
1068
  const ext = path.extname(absolutePath).toLowerCase();
@@ -1102,8 +1129,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1102
1129
  // Convert document via markit.
1103
1130
  const result = await convertFileWithMarkit(absolutePath, signal);
1104
1131
  if (result.ok) {
1105
- const renderedContent =
1106
- ext === ".pdf" ? rewritePdfImagePlaceholders(result.content, resolvedDisplayPath) : result.content;
1132
+ const renderedContent = result.content;
1107
1133
  // Route the converted markdown through the in-memory text builder
1108
1134
  // so line-range selectors (`file.pdf:50-100`, `:5-16,40-80`) and
1109
1135
  // raw mode apply against the converted output. Without this,
@@ -599,79 +599,111 @@ export function truncateDiffByHunk(
599
599
  let keptHunks = 0;
600
600
 
601
601
  for (const seg of segments) {
602
+ if (kept.length >= maxLines) break;
602
603
  if (seg.isChange) {
604
+ if (keptHunks >= maxHunks) break;
603
605
  keptHunks++;
604
- if (keptHunks > maxHunks) break;
605
606
  }
606
- kept.push(...seg.lines);
607
- if (kept.length >= maxLines) break;
607
+ const take = Math.min(seg.lines.length, maxLines - kept.length);
608
+ for (let i = 0; i < take; i++) {
609
+ kept.push(seg.lines[i]!);
610
+ }
608
611
  }
609
612
 
610
- const keptStats = getDiffStats(kept.join("\n"));
611
613
  return {
612
614
  text: kept.join("\n"),
613
- hiddenHunks: Math.max(0, totalStats.hunks - keptStats.hunks),
615
+ hiddenHunks: Math.max(0, totalStats.hunks - keptHunks),
614
616
  hiddenLines: Math.max(0, lines.length - kept.length),
615
617
  };
616
618
  }
617
619
 
618
620
  const contextBudget = maxLines - changeLineCount;
619
- const contextSegments = segments.filter(s => !s.isChange && !s.isEllipsis);
621
+ const contextSegments = segments.filter(s => !s.isChange);
620
622
  const totalContextLines = contextSegments.reduce((sum, s) => sum + s.lines.length, 0);
621
623
 
622
624
  const kept: string[] = [];
623
625
  let keptHunks = 0;
626
+ let keptSourceLines = 0;
624
627
 
625
628
  if (totalContextLines <= contextBudget) {
626
629
  for (const seg of segments) {
627
630
  if (seg.isChange) {
631
+ if (keptHunks >= maxHunks) break;
628
632
  keptHunks++;
629
- if (keptHunks > maxHunks) break;
630
633
  }
631
634
  kept.push(...seg.lines);
635
+ keptSourceLines += seg.lines.length;
632
636
  }
633
637
  } else {
634
- const contextRatio = contextSegments.length > 0 ? contextBudget / totalContextLines : 0;
638
+ const contextRatio = totalContextLines > 0 ? contextBudget / totalContextLines : 0;
639
+ let remainingContextBudget = contextBudget;
635
640
 
636
641
  for (let i = 0; i < segments.length; i++) {
637
642
  const seg = segments[i];
638
643
 
639
644
  if (seg.isChange) {
645
+ if (keptHunks >= maxHunks) break;
640
646
  keptHunks++;
641
- if (keptHunks > maxHunks) break;
642
- kept.push(...seg.lines);
643
- } else if (seg.isEllipsis) {
644
647
  kept.push(...seg.lines);
648
+ keptSourceLines += seg.lines.length;
649
+ continue;
650
+ }
651
+ if (remainingContextBudget <= 0) continue;
652
+
653
+ const allowedLines = Math.min(
654
+ remainingContextBudget,
655
+ Math.max(1, Math.floor(seg.lines.length * contextRatio)),
656
+ );
657
+ const outputStart = kept.length;
658
+ let sourceLinesAdded = 0;
659
+
660
+ if (seg.isEllipsis || seg.lines.length <= allowedLines) {
661
+ for (let j = 0; j < allowedLines; j++) {
662
+ kept.push(seg.lines[j]!);
663
+ }
664
+ sourceLinesAdded = allowedLines;
645
665
  } else {
646
- const allowedLines = Math.max(1, Math.floor(seg.lines.length * contextRatio));
647
666
  const isBeforeChange = segments[i + 1]?.isChange;
648
667
  const isAfterChange = segments[i - 1]?.isChange;
649
668
 
650
669
  if (isBeforeChange && isAfterChange) {
651
- const half = Math.ceil(allowedLines / 2);
652
- if (seg.lines.length > allowedLines) {
653
- kept.push(...seg.lines.slice(0, half));
670
+ if (allowedLines >= 3) {
671
+ const sourceBudget = allowedLines - 1;
672
+ const firstCount = Math.ceil(sourceBudget / 2);
673
+ const lastCount = sourceBudget - firstCount;
674
+ kept.push(...seg.lines.slice(0, firstCount));
654
675
  kept.push("");
655
- kept.push(...seg.lines.slice(-half));
676
+ if (lastCount > 0) kept.push(...seg.lines.slice(-lastCount));
677
+ sourceLinesAdded = sourceBudget;
656
678
  } else {
657
- kept.push(...seg.lines);
679
+ const firstCount = Math.ceil(allowedLines / 2);
680
+ const lastCount = allowedLines - firstCount;
681
+ kept.push(...seg.lines.slice(0, firstCount));
682
+ if (lastCount > 0) kept.push(...seg.lines.slice(-lastCount));
683
+ sourceLinesAdded = allowedLines;
658
684
  }
659
685
  } else if (isBeforeChange) {
660
686
  kept.push(...seg.lines.slice(-allowedLines));
687
+ sourceLinesAdded = allowedLines;
661
688
  } else if (isAfterChange) {
662
689
  kept.push(...seg.lines.slice(0, allowedLines));
690
+ sourceLinesAdded = allowedLines;
663
691
  } else {
664
- kept.push(...seg.lines.slice(0, Math.min(allowedLines, 2)));
692
+ const take = Math.min(allowedLines, 2);
693
+ kept.push(...seg.lines.slice(0, take));
694
+ sourceLinesAdded = take;
665
695
  }
666
696
  }
697
+
698
+ keptSourceLines += sourceLinesAdded;
699
+ remainingContextBudget -= kept.length - outputStart;
667
700
  }
668
701
  }
669
702
 
670
- const keptStats = getDiffStats(kept.join("\n"));
671
703
  return {
672
704
  text: kept.join("\n"),
673
- hiddenHunks: Math.max(0, totalStats.hunks - keptStats.hunks),
674
- hiddenLines: Math.max(0, lines.length - kept.length),
705
+ hiddenHunks: Math.max(0, totalStats.hunks - keptHunks),
706
+ hiddenLines: Math.max(0, lines.length - keptSourceLines),
675
707
  };
676
708
  }
677
709
 
@@ -8,14 +8,26 @@ import { getMarkdownTheme, type Theme } from "../modes/theme/theme";
8
8
  /** Whether a model transport can suppress native reasoning while private scratchpad thoughts are active. */
9
9
  export function supportsExternalThinking(model: Model | null | undefined): boolean {
10
10
  if (!model) return false;
11
+ const compat = model.compat;
11
12
  const requiresThinking =
12
13
  model.api === "anthropic-messages" &&
13
- model.compat !== undefined &&
14
- "requiresThinkingEnabled" in model.compat &&
15
- model.compat.requiresThinkingEnabled === true;
14
+ compat !== undefined &&
15
+ "requiresThinkingEnabled" in compat &&
16
+ compat.requiresThinkingEnabled === true;
16
17
  if (model.reasoning && (requiresThinking || (model.thinking?.requiresEffort && !model.thinking.suppressWhenOff))) {
17
18
  return false;
18
19
  }
20
+ // Transports that reject `reasoning.effort` (xAI Grok 4 and the other
21
+ // reasoning-only Responses models) cannot honour `forceReasoningOff`, so the
22
+ // scratchpad would run alongside native reasoning instead of replacing it.
23
+ if (
24
+ model.reasoning &&
25
+ compat !== undefined &&
26
+ (("omitReasoningEffort" in compat && compat.omitReasoningEffort === true) ||
27
+ ("supportsReasoningEffort" in compat && compat.supportsReasoningEffort === false))
28
+ ) {
29
+ return false;
30
+ }
19
31
  if (model.api === "google-generative-ai" || model.api === "google-gemini-cli" || model.api === "google-vertex") {
20
32
  return !model.reasoning || model.thinking?.mode === "budget" || model.thinking?.suppressWhenOff === true;
21
33
  }