@oh-my-pi/pi-coding-agent 3.15.0 → 3.20.0

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 (193) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/docs/extensions.md +1055 -0
  3. package/docs/rpc.md +69 -13
  4. package/docs/session-tree-plan.md +1 -1
  5. package/examples/extensions/README.md +141 -0
  6. package/examples/extensions/api-demo.ts +87 -0
  7. package/examples/extensions/chalk-logger.ts +26 -0
  8. package/examples/extensions/hello.ts +33 -0
  9. package/examples/extensions/pirate.ts +44 -0
  10. package/examples/extensions/plan-mode.ts +551 -0
  11. package/examples/extensions/subagent/agents/reviewer.md +35 -0
  12. package/examples/extensions/todo.ts +299 -0
  13. package/examples/extensions/tools.ts +145 -0
  14. package/examples/extensions/with-deps/index.ts +36 -0
  15. package/examples/extensions/with-deps/package-lock.json +31 -0
  16. package/examples/extensions/with-deps/package.json +16 -0
  17. package/examples/sdk/02-custom-model.ts +3 -3
  18. package/examples/sdk/05-tools.ts +7 -3
  19. package/examples/sdk/06-extensions.ts +81 -0
  20. package/examples/sdk/06-hooks.ts +14 -13
  21. package/examples/sdk/08-prompt-templates.ts +42 -0
  22. package/examples/sdk/08-slash-commands.ts +17 -12
  23. package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
  24. package/examples/sdk/12-full-control.ts +6 -6
  25. package/package.json +11 -7
  26. package/src/capability/extension-module.ts +34 -0
  27. package/src/cli/args.ts +22 -7
  28. package/src/cli/file-processor.ts +38 -67
  29. package/src/cli/list-models.ts +1 -1
  30. package/src/config.ts +25 -14
  31. package/src/core/agent-session.ts +505 -242
  32. package/src/core/auth-storage.ts +33 -21
  33. package/src/core/compaction/branch-summarization.ts +4 -4
  34. package/src/core/compaction/compaction.ts +3 -3
  35. package/src/core/custom-commands/bundled/wt/index.ts +430 -0
  36. package/src/core/custom-commands/loader.ts +9 -0
  37. package/src/core/custom-tools/wrapper.ts +5 -0
  38. package/src/core/event-bus.ts +59 -0
  39. package/src/core/export-html/vendor/highlight.min.js +1213 -0
  40. package/src/core/export-html/vendor/marked.min.js +6 -0
  41. package/src/core/extensions/index.ts +100 -0
  42. package/src/core/extensions/loader.ts +501 -0
  43. package/src/core/extensions/runner.ts +477 -0
  44. package/src/core/extensions/types.ts +712 -0
  45. package/src/core/extensions/wrapper.ts +147 -0
  46. package/src/core/hooks/types.ts +2 -2
  47. package/src/core/index.ts +10 -21
  48. package/src/core/keybindings.ts +199 -0
  49. package/src/core/messages.ts +26 -7
  50. package/src/core/model-registry.ts +123 -46
  51. package/src/core/model-resolver.ts +7 -5
  52. package/src/core/prompt-templates.ts +242 -0
  53. package/src/core/sdk.ts +378 -295
  54. package/src/core/session-manager.ts +72 -58
  55. package/src/core/settings-manager.ts +118 -22
  56. package/src/core/system-prompt.ts +24 -1
  57. package/src/core/terminal-notify.ts +37 -0
  58. package/src/core/tools/context.ts +4 -4
  59. package/src/core/tools/exa/mcp-client.ts +5 -4
  60. package/src/core/tools/exa/render.ts +176 -131
  61. package/src/core/tools/gemini-image.ts +361 -0
  62. package/src/core/tools/git.ts +216 -0
  63. package/src/core/tools/index.ts +28 -15
  64. package/src/core/tools/lsp/config.ts +5 -4
  65. package/src/core/tools/lsp/index.ts +17 -12
  66. package/src/core/tools/lsp/render.ts +39 -47
  67. package/src/core/tools/read.ts +66 -29
  68. package/src/core/tools/render-utils.ts +268 -0
  69. package/src/core/tools/renderers.ts +243 -225
  70. package/src/core/tools/task/discovery.ts +2 -2
  71. package/src/core/tools/task/executor.ts +66 -58
  72. package/src/core/tools/task/index.ts +29 -10
  73. package/src/core/tools/task/model-resolver.ts +8 -13
  74. package/src/core/tools/task/omp-command.ts +24 -0
  75. package/src/core/tools/task/render.ts +35 -60
  76. package/src/core/tools/task/types.ts +3 -0
  77. package/src/core/tools/web-fetch.ts +29 -28
  78. package/src/core/tools/web-search/index.ts +6 -5
  79. package/src/core/tools/web-search/providers/exa.ts +6 -5
  80. package/src/core/tools/web-search/render.ts +66 -111
  81. package/src/core/voice-controller.ts +135 -0
  82. package/src/core/voice-supervisor.ts +1003 -0
  83. package/src/core/voice.ts +308 -0
  84. package/src/discovery/builtin.ts +75 -1
  85. package/src/discovery/claude.ts +47 -1
  86. package/src/discovery/codex.ts +54 -2
  87. package/src/discovery/gemini.ts +55 -2
  88. package/src/discovery/helpers.ts +100 -1
  89. package/src/discovery/index.ts +2 -0
  90. package/src/index.ts +14 -9
  91. package/src/lib/worktree/collapse.ts +179 -0
  92. package/src/lib/worktree/constants.ts +14 -0
  93. package/src/lib/worktree/errors.ts +23 -0
  94. package/src/lib/worktree/git.ts +110 -0
  95. package/src/lib/worktree/index.ts +23 -0
  96. package/src/lib/worktree/operations.ts +216 -0
  97. package/src/lib/worktree/session.ts +114 -0
  98. package/src/lib/worktree/stats.ts +67 -0
  99. package/src/main.ts +61 -37
  100. package/src/migrations.ts +37 -7
  101. package/src/modes/interactive/components/bash-execution.ts +6 -4
  102. package/src/modes/interactive/components/custom-editor.ts +55 -0
  103. package/src/modes/interactive/components/custom-message.ts +95 -0
  104. package/src/modes/interactive/components/extensions/extension-list.ts +5 -0
  105. package/src/modes/interactive/components/extensions/inspector-panel.ts +18 -12
  106. package/src/modes/interactive/components/extensions/state-manager.ts +12 -0
  107. package/src/modes/interactive/components/extensions/types.ts +1 -0
  108. package/src/modes/interactive/components/footer.ts +324 -0
  109. package/src/modes/interactive/components/hook-editor.ts +1 -0
  110. package/src/modes/interactive/components/hook-selector.ts +3 -3
  111. package/src/modes/interactive/components/model-selector.ts +7 -6
  112. package/src/modes/interactive/components/oauth-selector.ts +3 -3
  113. package/src/modes/interactive/components/settings-defs.ts +55 -6
  114. package/src/modes/interactive/components/status-line/separators.ts +4 -4
  115. package/src/modes/interactive/components/status-line.ts +45 -35
  116. package/src/modes/interactive/components/tool-execution.ts +95 -23
  117. package/src/modes/interactive/interactive-mode.ts +644 -113
  118. package/src/modes/interactive/theme/defaults/alabaster.json +99 -0
  119. package/src/modes/interactive/theme/defaults/amethyst.json +103 -0
  120. package/src/modes/interactive/theme/defaults/anthracite.json +100 -0
  121. package/src/modes/interactive/theme/defaults/basalt.json +90 -0
  122. package/src/modes/interactive/theme/defaults/birch.json +101 -0
  123. package/src/modes/interactive/theme/defaults/dark-abyss.json +97 -0
  124. package/src/modes/interactive/theme/defaults/dark-aurora.json +94 -0
  125. package/src/modes/interactive/theme/defaults/dark-cavern.json +97 -0
  126. package/src/modes/interactive/theme/defaults/dark-copper.json +94 -0
  127. package/src/modes/interactive/theme/defaults/dark-cosmos.json +96 -0
  128. package/src/modes/interactive/theme/defaults/dark-eclipse.json +97 -0
  129. package/src/modes/interactive/theme/defaults/dark-ember.json +94 -0
  130. package/src/modes/interactive/theme/defaults/dark-equinox.json +96 -0
  131. package/src/modes/interactive/theme/defaults/dark-lavender.json +94 -0
  132. package/src/modes/interactive/theme/defaults/dark-lunar.json +95 -0
  133. package/src/modes/interactive/theme/defaults/dark-midnight.json +94 -0
  134. package/src/modes/interactive/theme/defaults/dark-nebula.json +96 -0
  135. package/src/modes/interactive/theme/defaults/dark-rainforest.json +97 -0
  136. package/src/modes/interactive/theme/defaults/dark-reef.json +97 -0
  137. package/src/modes/interactive/theme/defaults/dark-sakura.json +94 -0
  138. package/src/modes/interactive/theme/defaults/dark-slate.json +94 -0
  139. package/src/modes/interactive/theme/defaults/dark-solstice.json +96 -0
  140. package/src/modes/interactive/theme/defaults/dark-starfall.json +97 -0
  141. package/src/modes/interactive/theme/defaults/dark-swamp.json +96 -0
  142. package/src/modes/interactive/theme/defaults/dark-taiga.json +97 -0
  143. package/src/modes/interactive/theme/defaults/dark-terminal.json +94 -0
  144. package/src/modes/interactive/theme/defaults/dark-tundra.json +97 -0
  145. package/src/modes/interactive/theme/defaults/dark-twilight.json +97 -0
  146. package/src/modes/interactive/theme/defaults/dark-volcanic.json +97 -0
  147. package/src/modes/interactive/theme/defaults/graphite.json +99 -0
  148. package/src/modes/interactive/theme/defaults/index.ts +128 -0
  149. package/src/modes/interactive/theme/defaults/light-aurora-day.json +97 -0
  150. package/src/modes/interactive/theme/defaults/light-canyon.json +97 -0
  151. package/src/modes/interactive/theme/defaults/light-cirrus.json +96 -0
  152. package/src/modes/interactive/theme/defaults/light-coral.json +94 -0
  153. package/src/modes/interactive/theme/defaults/light-dawn.json +96 -0
  154. package/src/modes/interactive/theme/defaults/light-dunes.json +97 -0
  155. package/src/modes/interactive/theme/defaults/light-eucalyptus.json +94 -0
  156. package/src/modes/interactive/theme/defaults/light-frost.json +94 -0
  157. package/src/modes/interactive/theme/defaults/light-glacier.json +97 -0
  158. package/src/modes/interactive/theme/defaults/light-haze.json +96 -0
  159. package/src/modes/interactive/theme/defaults/light-honeycomb.json +94 -0
  160. package/src/modes/interactive/theme/defaults/light-lagoon.json +97 -0
  161. package/src/modes/interactive/theme/defaults/light-lavender.json +94 -0
  162. package/src/modes/interactive/theme/defaults/light-meadow.json +97 -0
  163. package/src/modes/interactive/theme/defaults/light-mint.json +94 -0
  164. package/src/modes/interactive/theme/defaults/light-opal.json +97 -0
  165. package/src/modes/interactive/theme/defaults/light-orchard.json +97 -0
  166. package/src/modes/interactive/theme/defaults/light-paper.json +94 -0
  167. package/src/modes/interactive/theme/defaults/light-prism.json +96 -0
  168. package/src/modes/interactive/theme/defaults/light-sand.json +94 -0
  169. package/src/modes/interactive/theme/defaults/light-savanna.json +97 -0
  170. package/src/modes/interactive/theme/defaults/light-soleil.json +96 -0
  171. package/src/modes/interactive/theme/defaults/light-wetland.json +97 -0
  172. package/src/modes/interactive/theme/defaults/light-zenith.json +95 -0
  173. package/src/modes/interactive/theme/defaults/limestone.json +100 -0
  174. package/src/modes/interactive/theme/defaults/mahogany.json +104 -0
  175. package/src/modes/interactive/theme/defaults/marble.json +99 -0
  176. package/src/modes/interactive/theme/defaults/obsidian.json +90 -0
  177. package/src/modes/interactive/theme/defaults/onyx.json +90 -0
  178. package/src/modes/interactive/theme/defaults/pearl.json +99 -0
  179. package/src/modes/interactive/theme/defaults/porcelain.json +90 -0
  180. package/src/modes/interactive/theme/defaults/quartz.json +102 -0
  181. package/src/modes/interactive/theme/defaults/sandstone.json +101 -0
  182. package/src/modes/interactive/theme/defaults/titanium.json +89 -0
  183. package/src/modes/print-mode.ts +14 -72
  184. package/src/modes/rpc/rpc-client.ts +23 -9
  185. package/src/modes/rpc/rpc-mode.ts +137 -125
  186. package/src/modes/rpc/rpc-types.ts +46 -24
  187. package/src/prompts/task.md +1 -0
  188. package/src/prompts/tools/gemini-image.md +4 -0
  189. package/src/prompts/tools/git.md +9 -0
  190. package/src/prompts/voice-summary.md +12 -0
  191. package/src/utils/image-convert.ts +26 -0
  192. package/src/utils/image-resize.ts +215 -0
  193. package/src/utils/shell-snapshot.ts +22 -20
@@ -5,6 +5,7 @@ export { createEditTool, type EditToolOptions, editTool } from "./edit";
5
5
  export { exaTools } from "./exa/index";
6
6
  export type { ExaRenderDetails, ExaSearchResponse, ExaSearchResult } from "./exa/types";
7
7
  export { createFindTool, type FindToolDetails, findTool } from "./find";
8
+ export { createGitTool, type GitToolDetails, gitTool } from "./git";
8
9
  export { createGrepTool, type GrepToolDetails, grepTool } from "./grep";
9
10
  export { createLsTool, type LsToolDetails, lsTool } from "./ls";
10
11
  export {
@@ -20,7 +21,7 @@ export {
20
21
  } from "./lsp/index";
21
22
  export { createNotebookTool, type NotebookToolDetails, notebookTool } from "./notebook";
22
23
  export { createOutputTool, type OutputToolDetails, outputTool } from "./output";
23
- export { createReadTool, type ReadToolDetails, readTool } from "./read";
24
+ export { createReadTool, type ReadToolDetails, type ReadToolOptions, readTool } from "./read";
24
25
  export { createReportFindingTool, createSubmitReviewTool, reportFindingTool, submitReviewTool } from "./review";
25
26
  export {
26
27
  createRulebookTool,
@@ -57,6 +58,7 @@ import { bashTool, createBashTool } from "./bash";
57
58
  import { checkBashInterception, checkSimpleLsInterception } from "./bash-interceptor";
58
59
  import { createEditTool, editTool } from "./edit";
59
60
  import { createFindTool, findTool } from "./find";
61
+ import { createGitTool, gitTool } from "./git";
60
62
  import { createGrepTool, grepTool } from "./grep";
61
63
  import { createLsTool, lsTool } from "./ls";
62
64
  import { createLspTool, createLspWritethrough, lspTool } from "./lsp/index";
@@ -87,17 +89,22 @@ export interface CodingToolsOptions {
87
89
  lspFormatOnWrite?: boolean;
88
90
  /** Whether to accept high-confidence fuzzy matches in edit tool (default: true) */
89
91
  editFuzzyMatch?: boolean;
92
+ /** Whether to auto-resize images to 2000x2000 max in read tool (default: true) */
93
+ readAutoResizeImages?: boolean;
90
94
  /** Set of tool names available to the agent (for cross-tool awareness) */
91
95
  availableTools?: Set<string>;
92
96
  }
93
97
 
94
98
  // Factory function type
95
- type ToolFactory = (cwd: string, sessionContext?: SessionContext, options?: CodingToolsOptions) => Tool;
99
+ type ToolFactory = (cwd: string, sessionContext?: SessionContext, options?: CodingToolsOptions) => Tool | Promise<Tool>;
96
100
 
97
101
  // Tool definitions: static tools and their factory functions
98
102
  const toolDefs: Record<string, { tool: Tool; create: ToolFactory }> = {
99
103
  ask: { tool: askTool, create: createAskTool },
100
- read: { tool: readTool, create: createReadTool },
104
+ read: {
105
+ tool: readTool,
106
+ create: (cwd, _ctx, options) => createReadTool(cwd, { autoResizeImages: options?.readAutoResizeImages ?? true }),
107
+ },
101
108
  bash: { tool: bashTool, create: createBashTool },
102
109
  edit: {
103
110
  tool: editTool,
@@ -125,6 +132,7 @@ const toolDefs: Record<string, { tool: Tool; create: ToolFactory }> = {
125
132
  },
126
133
  grep: { tool: grepTool, create: createGrepTool },
127
134
  find: { tool: findTool, create: createFindTool },
135
+ git: { tool: gitTool, create: createGitTool },
128
136
  ls: { tool: lsTool, create: createLsTool },
129
137
  lsp: { tool: lspTool, create: createLspTool },
130
138
  notebook: { tool: notebookTool, create: createNotebookTool },
@@ -142,13 +150,14 @@ export type ToolName = keyof typeof toolDefs;
142
150
  const uiToolNames: ToolName[] = ["ask"];
143
151
 
144
152
  // Tool sets defined by name (base sets, without UI-only tools)
145
- const baseCodingToolNames: ToolName[] = [
153
+ export const baseCodingToolNames: ToolName[] = [
146
154
  "read",
147
155
  "bash",
148
156
  "edit",
149
157
  "write",
150
158
  "grep",
151
159
  "find",
160
+ "git",
152
161
  "ls",
153
162
  "lsp",
154
163
  "notebook",
@@ -178,15 +187,15 @@ export const allTools = Object.fromEntries(Object.entries(toolDefs).map(([name,
178
187
  * @param sessionContext - Optional session context for tools that need it
179
188
  * @param options - Options for tool configuration
180
189
  */
181
- export function createCodingTools(
190
+ export async function createCodingTools(
182
191
  cwd: string,
183
192
  hasUI = false,
184
193
  sessionContext?: SessionContext,
185
194
  options?: CodingToolsOptions,
186
- ): Tool[] {
195
+ ): Promise<Tool[]> {
187
196
  const names = hasUI ? [...baseCodingToolNames, ...uiToolNames] : baseCodingToolNames;
188
197
  const optionsWithTools = { ...options, availableTools: new Set(names) };
189
- return names.map((name) => toolDefs[name].create(cwd, sessionContext, optionsWithTools));
198
+ return Promise.all(names.map((name) => toolDefs[name].create(cwd, sessionContext, optionsWithTools)));
190
199
  }
191
200
 
192
201
  /**
@@ -196,15 +205,15 @@ export function createCodingTools(
196
205
  * @param sessionContext - Optional session context for tools that need it
197
206
  * @param options - Options for tool configuration
198
207
  */
199
- export function createReadOnlyTools(
208
+ export async function createReadOnlyTools(
200
209
  cwd: string,
201
210
  hasUI = false,
202
211
  sessionContext?: SessionContext,
203
212
  options?: CodingToolsOptions,
204
- ): Tool[] {
213
+ ): Promise<Tool[]> {
205
214
  const names = hasUI ? [...baseReadOnlyToolNames, ...uiToolNames] : baseReadOnlyToolNames;
206
215
  const optionsWithTools = { ...options, availableTools: new Set(names) };
207
- return names.map((name) => toolDefs[name].create(cwd, sessionContext, optionsWithTools));
216
+ return Promise.all(names.map((name) => toolDefs[name].create(cwd, sessionContext, optionsWithTools)));
208
217
  }
209
218
 
210
219
  /**
@@ -213,16 +222,20 @@ export function createReadOnlyTools(
213
222
  * @param sessionContext - Optional session context for tools that need it
214
223
  * @param options - Options for tool configuration
215
224
  */
216
- export function createAllTools(
225
+ export async function createAllTools(
217
226
  cwd: string,
218
227
  sessionContext?: SessionContext,
219
228
  options?: CodingToolsOptions,
220
- ): Record<ToolName, Tool> {
229
+ ): Promise<Record<ToolName, Tool>> {
221
230
  const names = Object.keys(toolDefs);
222
231
  const optionsWithTools = { ...options, availableTools: new Set(names) };
223
- return Object.fromEntries(
224
- Object.entries(toolDefs).map(([name, def]) => [name, def.create(cwd, sessionContext, optionsWithTools)]),
225
- ) as Record<ToolName, Tool>;
232
+ const entries = await Promise.all(
233
+ Object.entries(toolDefs).map(async ([name, def]) => [
234
+ name,
235
+ await def.create(cwd, sessionContext, optionsWithTools),
236
+ ]),
237
+ );
238
+ return Object.fromEntries(entries) as Record<ToolName, Tool>;
226
239
  }
227
240
 
228
241
  /**
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { extname, join } from "node:path";
4
- import { getConfigDirPaths } from "../../../config.js";
4
+ import { getConfigDirPaths } from "../../../config";
5
5
  import { createBiomeClient } from "./clients/biome-client";
6
6
  import type { ServerConfig } from "./types";
7
7
 
@@ -618,7 +618,7 @@ export function hasRootMarkers(cwd: string, markers: string[]): boolean {
618
618
  // Handle glob-like patterns (e.g., "*.cabal")
619
619
  if (marker.includes("*")) {
620
620
  try {
621
- const { globSync } = require("node:fs");
621
+ const { globSync } = require("glob");
622
622
  const matches = globSync(join(cwd, marker));
623
623
  return matches.length > 0;
624
624
  } catch {
@@ -626,7 +626,8 @@ export function hasRootMarkers(cwd: string, markers: string[]): boolean {
626
626
  return false;
627
627
  }
628
628
  }
629
- return existsSync(join(cwd, marker));
629
+ const filePath = join(cwd, marker);
630
+ return existsSync(filePath);
630
631
  });
631
632
  }
632
633
 
@@ -741,7 +742,7 @@ function getConfigPaths(cwd: string): string[] {
741
742
  * }
742
743
  * ```
743
744
  */
744
- export function loadConfig(cwd: string): LspConfig {
745
+ export async function loadConfig(cwd: string): Promise<LspConfig> {
745
746
  const configPaths = getConfigPaths(cwd);
746
747
 
747
748
  for (const configPath of configPaths) {
@@ -1,4 +1,5 @@
1
- import * as fs from "node:fs";
1
+ import type { Dirent } from "node:fs";
2
+ import { existsSync } from "node:fs";
2
3
  import path from "node:path";
3
4
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
4
5
  import type { BunFile } from "bun";
@@ -78,7 +79,7 @@ export interface LspWarmupResult {
78
79
  * @returns Status of each server that was started
79
80
  */
80
81
  export async function warmupLspServers(cwd: string): Promise<LspWarmupResult> {
81
- const config = loadConfig(cwd);
82
+ const config = await loadConfig(cwd);
82
83
  setIdleTimeout(config.idleTimeoutMs);
83
84
  const servers: LspWarmupResult["servers"] = [];
84
85
  const lspServers = getLspServers(config);
@@ -173,10 +174,10 @@ async function notifyFileSaved(
173
174
  // Cache config per cwd to avoid repeated file I/O
174
175
  const configCache = new Map<string, LspConfig>();
175
176
 
176
- function getConfig(cwd: string): LspConfig {
177
+ async function getConfig(cwd: string): Promise<LspConfig> {
177
178
  let config = configCache.get(cwd);
178
179
  if (!config) {
179
- config = loadConfig(cwd);
180
+ config = await loadConfig(cwd);
180
181
  setIdleTimeout(config.idleTimeoutMs);
181
182
  configCache.set(cwd, config);
182
183
  }
@@ -225,9 +226,13 @@ function findFileByExtensions(baseDir: string, extensions: string[], maxDepth: n
225
226
  const normalized = extensions.map((ext) => ext.toLowerCase());
226
227
  const search = (dir: string, depth: number): string | null => {
227
228
  if (depth > maxDepth) return null;
228
- let entries: fs.Dirent[];
229
+ let entries: Dirent[];
229
230
  try {
230
- entries = fs.readdirSync(dir, { withFileTypes: true });
231
+ entries = Array.from(new Bun.Glob("*").scanSync({ cwd: dir, onlyFiles: false })).map((name) => ({
232
+ name,
233
+ isFile: () => !existsSync(path.join(dir, name)) || Bun.file(path.join(dir, name)).type !== "directory",
234
+ isDirectory: () => existsSync(path.join(dir, name)) && Bun.file(path.join(dir, name)).type === "directory",
235
+ })) as Dirent[];
231
236
  } catch {
232
237
  return null;
233
238
  }
@@ -312,22 +317,22 @@ interface ProjectType {
312
317
  /** Detect project type from root markers */
313
318
  function detectProjectType(cwd: string): ProjectType {
314
319
  // Check for Rust (Cargo.toml)
315
- if (fs.existsSync(path.join(cwd, "Cargo.toml"))) {
320
+ if (existsSync(path.join(cwd, "Cargo.toml"))) {
316
321
  return { type: "rust", command: ["cargo", "check", "--message-format=short"], description: "Rust (cargo check)" };
317
322
  }
318
323
 
319
324
  // Check for TypeScript (tsconfig.json)
320
- if (fs.existsSync(path.join(cwd, "tsconfig.json"))) {
325
+ if (existsSync(path.join(cwd, "tsconfig.json"))) {
321
326
  return { type: "typescript", command: ["npx", "tsc", "--noEmit"], description: "TypeScript (tsc --noEmit)" };
322
327
  }
323
328
 
324
329
  // Check for Go (go.mod)
325
- if (fs.existsSync(path.join(cwd, "go.mod"))) {
330
+ if (existsSync(path.join(cwd, "go.mod"))) {
326
331
  return { type: "go", command: ["go", "build", "./..."], description: "Go (go build)" };
327
332
  }
328
333
 
329
334
  // Check for Python (pyproject.toml or pyrightconfig.json)
330
- if (fs.existsSync(path.join(cwd, "pyproject.toml")) || fs.existsSync(path.join(cwd, "pyrightconfig.json"))) {
335
+ if (existsSync(path.join(cwd, "pyproject.toml")) || existsSync(path.join(cwd, "pyrightconfig.json"))) {
331
336
  return { type: "python", command: ["pyright"], description: "Python (pyright)" };
332
337
  }
333
338
 
@@ -612,7 +617,7 @@ export function createLspWritethrough(cwd: string, options?: WritethroughOptions
612
617
  return writethroughNoop;
613
618
  }
614
619
  return async (dst: string, content: string, signal?: AbortSignal, file?: BunFile) => {
615
- const config = getConfig(cwd);
620
+ const config = await getConfig(cwd);
616
621
  const servers = getServersForFile(config, dst);
617
622
  if (servers.length === 0) {
618
623
  return writethroughNoop(dst, content, signal, file);
@@ -708,7 +713,7 @@ export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolD
708
713
  include_declaration,
709
714
  } = params;
710
715
 
711
- const config = getConfig(cwd);
716
+ const config = await getConfig(cwd);
712
717
 
713
718
  // Status action doesn't need a file
714
719
  if (action === "status") {
@@ -11,7 +11,8 @@
11
11
  import type { AgentToolResult, RenderResultOptions } from "@oh-my-pi/pi-agent-core";
12
12
  import { Text } from "@oh-my-pi/pi-tui";
13
13
  import { highlight, supportsLanguage } from "cli-highlight";
14
- import type { Theme } from "../../../modes/interactive/theme/theme";
14
+ import { getLanguageFromPath, type Theme } from "../../../modes/interactive/theme/theme";
15
+ import { formatExpandHint, formatMoreItems, TRUNCATE_LENGTHS, truncate } from "../render-utils";
15
16
  import type { LspParams, LspToolDetails } from "./types";
16
17
 
17
18
  // =============================================================================
@@ -25,8 +26,8 @@ import type { LspParams, LspToolDetails } from "./types";
25
26
  export function renderCall(args: unknown, theme: Theme): Text {
26
27
  const p = args as LspParams & { file?: string; files?: string[] };
27
28
 
28
- let text = theme.fg("toolTitle", theme.bold("LSP "));
29
- text += theme.fg("accent", p.action || "?");
29
+ let text = theme.fg("toolTitle", theme.bold("LSP"));
30
+ text += ` ${theme.fg("accent", p.action || "?")}`;
30
31
 
31
32
  if (p.file) {
32
33
  text += ` ${theme.fg("muted", p.file)}`;
@@ -112,7 +113,7 @@ function renderHover(
112
113
  const v = theme.boxSharp.vertical;
113
114
  const top = `${theme.boxSharp.topLeft}${h.repeat(3)}`;
114
115
  const bottom = `${theme.boxSharp.bottomLeft}${h.repeat(3)}`;
115
- let output = `${icon} ${theme.fg("toolTitle", "Hover")}${langLabel}`;
116
+ let output = `${icon}${langLabel}`;
116
117
  output += `\n ${theme.fg("mdCodeBlockBorder", top)}`;
117
118
  for (const line of codeLines) {
118
119
  output += `\n ${theme.fg("mdCodeBlockBorder", v)} ${line}`;
@@ -126,9 +127,10 @@ function renderHover(
126
127
 
127
128
  // Collapsed view
128
129
  const firstCodeLine = codeLines[0] || "";
129
- const expandHint = theme.fg("dim", " (Ctrl+O to expand)");
130
+ const hasMore = codeLines.length > 1 || Boolean(afterCode);
131
+ const expandHint = formatExpandHint(false, hasMore, theme);
130
132
 
131
- let output = `${icon} ${theme.fg("toolTitle", "Hover")}${langLabel}${expandHint}`;
133
+ let output = `${icon}${langLabel}${expandHint}`;
132
134
  const h = theme.boxSharp.horizontal;
133
135
  const v = theme.boxSharp.vertical;
134
136
  const bottom = `${theme.boxSharp.bottomLeft}${h.repeat(3)}`;
@@ -142,9 +144,7 @@ function renderHover(
142
144
  }
143
145
 
144
146
  if (afterCode) {
145
- const ellipsis = theme.format.ellipsis;
146
- const sliceLen = Math.max(0, 60 - ellipsis.length);
147
- const docPreview = afterCode.length > 60 ? `${afterCode.slice(0, sliceLen)}${ellipsis}` : afterCode;
147
+ const docPreview = truncate(afterCode, TRUNCATE_LENGTHS.TITLE, theme.format.ellipsis);
148
148
  output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", docPreview)}`;
149
149
  } else {
150
150
  output += `\n ${theme.fg("mdCodeBlockBorder", bottom)}`;
@@ -186,6 +186,12 @@ function highlightCode(codeText: string, language: string, theme: Theme): string
186
186
  // Diagnostics Rendering
187
187
  // =============================================================================
188
188
 
189
+ function formatDiagnosticLocation(file: string, line: string | number, col: string | number, theme: Theme): string {
190
+ const lang = getLanguageFromPath(file);
191
+ const icon = theme.fg("muted", theme.getLangIcon(lang));
192
+ return `${icon} ${file}:${line}:${col}`;
193
+ }
194
+
189
195
  /**
190
196
  * Render diagnostics with color-coded severity.
191
197
  */
@@ -218,7 +224,7 @@ function renderDiagnostics(
218
224
  const fallbackDiagnostics: RawDiagnostic[] = diagLines.map((line) => ({ raw: line.trim() }));
219
225
 
220
226
  if (expanded) {
221
- let output = `${icon} ${theme.fg("toolTitle", "Diagnostics")} ${theme.fg("dim", meta.join(", "))}`;
227
+ let output = `${icon} ${theme.fg("dim", meta.join(theme.sep.dot))}`;
222
228
  const items: DiagnosticItem[] = parsedDiagnostics.length > 0 ? parsedDiagnostics : fallbackDiagnostics;
223
229
  for (let i = 0; i < items.length; i++) {
224
230
  const item = items[i];
@@ -230,26 +236,25 @@ function renderDiagnostics(
230
236
  continue;
231
237
  }
232
238
  const severityColor = severityToColor(item.severity);
233
- const location = `${item.file}:${item.line}:${item.col}`;
239
+ const location = formatDiagnosticLocation(item.file, item.line, item.col, theme);
234
240
  output += `\n ${theme.fg("dim", branch)} ${theme.fg(severityColor, location)} ${theme.fg(
235
241
  "dim",
236
242
  `[${item.severity}]`,
237
243
  )}`;
238
244
  if (item.message) {
239
- output += `\n ${theme.fg("dim", detailPrefix)}${theme.fg("muted", trimTo(item.message, 120, theme))}`;
245
+ output += `\n ${theme.fg("dim", detailPrefix)}${theme.fg("muted", truncate(item.message, TRUNCATE_LENGTHS.LINE, theme.format.ellipsis))}`;
240
246
  }
241
247
  }
242
248
  return new Text(output, 0, 0);
243
249
  }
244
250
 
245
251
  // Collapsed view
246
- const expandHint = theme.fg("dim", " (Ctrl+O to expand)");
247
- let output = `${icon} ${theme.fg("toolTitle", "Diagnostics")} ${theme.fg("dim", meta.join(", "))}${expandHint}`;
248
-
249
252
  const previewItems: DiagnosticItem[] =
250
253
  parsedDiagnostics.length > 0 ? parsedDiagnostics.slice(0, 3) : fallbackDiagnostics.slice(0, 3);
251
254
  const remaining =
252
255
  (parsedDiagnostics.length > 0 ? parsedDiagnostics.length : fallbackDiagnostics.length) - previewItems.length;
256
+ const expandHint = formatExpandHint(false, remaining > 0, theme);
257
+ let output = `${icon} ${theme.fg("dim", meta.join(theme.sep.dot))}${expandHint}`;
253
258
  for (let i = 0; i < previewItems.length; i++) {
254
259
  const item = previewItems[i];
255
260
  const isLast = i === previewItems.length - 1 && remaining <= 0;
@@ -259,15 +264,14 @@ function renderDiagnostics(
259
264
  continue;
260
265
  }
261
266
  const severityColor = severityToColor(item.severity);
262
- const location = `${item.file}:${item.line}:${item.col}`;
263
- const message = item.message ? ` ${theme.fg("muted", trimTo(item.message, 80, theme))}` : "";
267
+ const location = formatDiagnosticLocation(item.file, item.line, item.col, theme);
268
+ const message = item.message
269
+ ? ` ${theme.fg("muted", truncate(item.message, TRUNCATE_LENGTHS.CONTENT, theme.format.ellipsis))}`
270
+ : "";
264
271
  output += `\n ${theme.fg("dim", branch)} ${theme.fg(severityColor, location)}${message}`;
265
272
  }
266
273
  if (remaining > 0) {
267
- output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
268
- "muted",
269
- `${theme.format.ellipsis} ${remaining} more`,
270
- )}`;
274
+ output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", `${theme.format.ellipsis} ${remaining} more`)}`;
271
275
  }
272
276
 
273
277
  return new Text(output, 0, 0);
@@ -301,8 +305,8 @@ function renderReferences(refMatch: RegExpMatchArray, lines: string[], expanded:
301
305
  const files = Array.from(byFile.keys());
302
306
 
303
307
  const renderGrouped = (maxFiles: number, maxLocsPerFile: number, showHint: boolean): string => {
304
- const expandHint = showHint ? theme.fg("dim", " (Ctrl+O to expand)") : "";
305
- let output = `${icon} ${theme.fg("toolTitle", "References")} ${theme.fg("dim", `${refCount} found`)}${expandHint}`;
308
+ const expandHint = formatExpandHint(false, showHint, theme);
309
+ let output = `${icon} ${theme.fg("dim", `${refCount} found`)}${expandHint}`;
306
310
 
307
311
  const filesToShow = files.slice(0, maxFiles);
308
312
  for (let fi = 0; fi < filesToShow.length; fi++) {
@@ -330,7 +334,7 @@ function renderReferences(refMatch: RegExpMatchArray, lines: string[], expanded:
330
334
  const context = `at ${file}:${line}:${col}`;
331
335
  output += `\n ${theme.fg("dim", fileCont)}${theme.fg("dim", locCont)}${theme.fg(
332
336
  "muted",
333
- trimTo(context, 120, theme),
337
+ truncate(context, TRUNCATE_LENGTHS.LINE, theme.format.ellipsis),
334
338
  )}`;
335
339
  }
336
340
  }
@@ -346,7 +350,7 @@ function renderReferences(refMatch: RegExpMatchArray, lines: string[], expanded:
346
350
  if (files.length > maxFiles) {
347
351
  output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
348
352
  "muted",
349
- `${theme.format.ellipsis} ${files.length - maxFiles} more files`,
353
+ formatMoreItems(files.length - maxFiles, "file", theme),
350
354
  )}`;
351
355
  }
352
356
 
@@ -424,7 +428,7 @@ function renderSymbols(symbolsMatch: RegExpMatchArray, lines: string[], expanded
424
428
  const topLevelCount = symbols.filter((s) => s.indent === 0).length;
425
429
 
426
430
  if (expanded) {
427
- let output = `${icon} ${theme.fg("toolTitle", "Symbols")} ${theme.fg("dim", `in ${fileName}`)}`;
431
+ let output = `${icon} ${theme.fg("dim", `in ${fileName}`)}`;
428
432
 
429
433
  for (let i = 0; i < symbols.length; i++) {
430
434
  const sym = symbols[i];
@@ -442,10 +446,10 @@ function renderSymbols(symbolsMatch: RegExpMatchArray, lines: string[], expanded
442
446
  }
443
447
 
444
448
  // Collapsed: show first 3 top-level symbols
445
- const expandHint = theme.fg("dim", " (Ctrl+O to expand)");
446
- let output = `${icon} ${theme.fg("toolTitle", "Symbols")} ${theme.fg("dim", `in ${fileName}`)}${expandHint}`;
447
-
448
449
  const topLevel = symbols.filter((s) => s.indent === 0).slice(0, 3);
450
+ const hasMoreSymbols = symbols.length > topLevel.length;
451
+ const expandHint = formatExpandHint(false, hasMoreSymbols, theme);
452
+ let output = `${icon} ${theme.fg("dim", `in ${fileName}`)}${expandHint}`;
449
453
  for (let i = 0; i < topLevel.length; i++) {
450
454
  const sym = topLevel[i];
451
455
  const isLast = i === topLevel.length - 1 && topLevelCount <= 3;
@@ -456,10 +460,7 @@ function renderSymbols(symbolsMatch: RegExpMatchArray, lines: string[], expanded
456
460
  )} ${theme.fg("muted", `line ${sym.line}`)}`;
457
461
  }
458
462
  if (topLevelCount > 3) {
459
- output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
460
- "muted",
461
- `${theme.format.ellipsis} ${topLevelCount - 3} more`,
462
- )}`;
463
+ output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", `${theme.format.ellipsis} ${topLevelCount - 3} more`)}`;
463
464
  }
464
465
 
465
466
  return new Text(output, 0, 0);
@@ -484,7 +485,7 @@ function renderGeneric(text: string, lines: string[], expanded: boolean, theme:
484
485
  : theme.styledSymbol("status.info", "accent");
485
486
 
486
487
  if (expanded) {
487
- let output = `${icon} ${theme.fg("toolTitle", "LSP")} ${theme.fg("dim", "Output")}`;
488
+ let output = `${icon} ${theme.fg("dim", "Output")}`;
488
489
  for (let i = 0; i < lines.length; i++) {
489
490
  const isLast = i === lines.length - 1;
490
491
  const branch = isLast ? theme.tree.last : theme.tree.branch;
@@ -494,21 +495,18 @@ function renderGeneric(text: string, lines: string[], expanded: boolean, theme:
494
495
  }
495
496
 
496
497
  const firstLine = lines[0] || "No output";
497
- const expandHint = lines.length > 1 ? theme.fg("dim", " (Ctrl+O to expand)") : "";
498
- let output = `${icon} ${theme.fg("toolTitle", "LSP")} ${theme.fg("dim", firstLine.slice(0, 60))}${expandHint}`;
498
+ const expandHint = formatExpandHint(false, lines.length > 1, theme);
499
+ let output = `${icon} ${theme.fg("dim", truncate(firstLine, TRUNCATE_LENGTHS.TITLE, theme.format.ellipsis))}${expandHint}`;
499
500
 
500
501
  if (lines.length > 1) {
501
502
  const previewLines = lines.slice(1, 4);
502
503
  for (let i = 0; i < previewLines.length; i++) {
503
504
  const isLast = i === previewLines.length - 1 && lines.length <= 4;
504
505
  const branch = isLast ? theme.tree.last : theme.tree.branch;
505
- output += `\n ${theme.fg("dim", branch)} ${theme.fg("dim", previewLines[i].trim().slice(0, 80))}`;
506
+ output += `\n ${theme.fg("dim", branch)} ${theme.fg("dim", truncate(previewLines[i].trim(), TRUNCATE_LENGTHS.CONTENT, theme.format.ellipsis))}`;
506
507
  }
507
508
  if (lines.length > 4) {
508
- output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
509
- "muted",
510
- `${theme.format.ellipsis} ${lines.length - 4} more lines`,
511
- )}`;
509
+ output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", formatMoreItems(lines.length - 4, "line", theme))}`;
512
510
  }
513
511
  }
514
512
 
@@ -552,9 +550,3 @@ function severityToColor(severity: string): "error" | "warning" | "accent" | "di
552
550
  return "dim";
553
551
  }
554
552
  }
555
-
556
- function trimTo(value: string, maxLength: number, theme: Theme): string {
557
- if (value.length <= maxLength) return value;
558
- const sliceLen = Math.max(0, maxLength - theme.format.ellipsis.length);
559
- return `${value.slice(0, sliceLen)}${theme.format.ellipsis}`;
560
- }
@@ -1,12 +1,11 @@
1
- import { spawnSync } from "node:child_process";
2
- import { constants, existsSync } from "node:fs";
3
- import { access, readFile, stat } from "node:fs/promises";
1
+ import { existsSync } from "node:fs";
4
2
  import path from "node:path";
5
3
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
6
4
  import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
7
5
  import { Type } from "@sinclair/typebox";
8
6
  import { globSync } from "glob";
9
7
  import readDescription from "../../prompts/tools/read.md" with { type: "text" };
8
+ import { formatDimensionNote, resizeImage } from "../../utils/image-resize";
10
9
  import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime";
11
10
  import { ensureTool } from "../../utils/tools-manager";
12
11
  import { untilAborted } from "../utils";
@@ -49,9 +48,14 @@ async function findExistingDirectory(startDir: string): Promise<string | null> {
49
48
 
50
49
  while (true) {
51
50
  try {
52
- const stats = await stat(current);
53
- if (stats.isDirectory()) {
54
- return current;
51
+ if (existsSync(current)) {
52
+ // Check if directory by trying to read it as dir
53
+ try {
54
+ await Bun.$`test -d ${current}`.quiet();
55
+ return current;
56
+ } catch {
57
+ // Not a directory, continue
58
+ }
55
59
  }
56
60
  } catch {
57
61
  // Keep walking up.
@@ -300,17 +304,17 @@ function convertWithMarkitdown(filePath: string): { content: string; ok: boolean
300
304
  return { content: "", ok: false, error: "markitdown not found" };
301
305
  }
302
306
 
303
- const result = spawnSync(cmd, [filePath], {
304
- encoding: "utf-8",
305
- timeout: 60000,
306
- maxBuffer: 50 * 1024 * 1024,
307
+ const result = Bun.spawnSync([cmd, filePath], {
308
+ stdin: "ignore",
309
+ stdout: "pipe",
310
+ stderr: "pipe",
307
311
  });
308
312
 
309
- if (result.status === 0 && result.stdout && result.stdout.length > 0) {
310
- return { content: result.stdout, ok: true };
313
+ if (result.exitCode === 0 && result.stdout && result.stdout.length > 0) {
314
+ return { content: result.stdout.toString(), ok: true };
311
315
  }
312
316
 
313
- return { content: "", ok: false, error: result.stderr || "Conversion failed" };
317
+ return { content: "", ok: false, error: result.stderr.toString() || "Conversion failed" };
314
318
  }
315
319
 
316
320
  const readSchema = Type.Object({
@@ -324,7 +328,13 @@ export interface ReadToolDetails {
324
328
  redirectedTo?: "ls";
325
329
  }
326
330
 
327
- export function createReadTool(cwd: string): AgentTool<typeof readSchema> {
331
+ export interface ReadToolOptions {
332
+ /** Whether to auto-resize images to 2000x2000 max. Default: true */
333
+ autoResizeImages?: boolean;
334
+ }
335
+
336
+ export function createReadTool(cwd: string, options?: ReadToolOptions): AgentTool<typeof readSchema> {
337
+ const autoResizeImages = options?.autoResizeImages ?? true;
328
338
  const lsTool = createLsTool(cwd);
329
339
  return {
330
340
  name: "read",
@@ -339,9 +349,21 @@ export function createReadTool(cwd: string): AgentTool<typeof readSchema> {
339
349
  const absolutePath = resolveReadPath(readPath, cwd);
340
350
 
341
351
  return untilAborted(signal, async () => {
342
- let fileStat: Awaited<ReturnType<typeof stat>>;
352
+ let isDirectory = false;
353
+ let fileSize = 0;
343
354
  try {
344
- fileStat = await stat(absolutePath);
355
+ if (!existsSync(absolutePath)) {
356
+ throw { code: "ENOENT" };
357
+ }
358
+ const file = Bun.file(absolutePath);
359
+ fileSize = file.size;
360
+ // Check if directory
361
+ try {
362
+ await Bun.$`test -d ${absolutePath}`.quiet();
363
+ isDirectory = true;
364
+ } catch {
365
+ isDirectory = false;
366
+ }
345
367
  } catch (error) {
346
368
  if (isNotFoundError(error)) {
347
369
  const suggestions = await findReadPathSuggestions(readPath, cwd);
@@ -366,7 +388,7 @@ export function createReadTool(cwd: string): AgentTool<typeof readSchema> {
366
388
  throw error;
367
389
  }
368
390
 
369
- if (fileStat.isDirectory()) {
391
+ if (isDirectory) {
370
392
  const lsResult = await lsTool.execute(toolCallId, { path: readPath, limit }, signal);
371
393
  return {
372
394
  content: lsResult.content,
@@ -374,8 +396,6 @@ export function createReadTool(cwd: string): AgentTool<typeof readSchema> {
374
396
  };
375
397
  }
376
398
 
377
- await access(absolutePath, constants.R_OK);
378
-
379
399
  const mimeType = await detectSupportedImageMimeTypeFromFile(absolutePath);
380
400
  const ext = path.extname(absolutePath).toLowerCase();
381
401
 
@@ -385,9 +405,8 @@ export function createReadTool(cwd: string): AgentTool<typeof readSchema> {
385
405
 
386
406
  if (mimeType) {
387
407
  // Check image file size before reading to prevent OOM during serialization
388
- const fileStat = await stat(absolutePath);
389
- if (fileStat.size > MAX_IMAGE_SIZE) {
390
- const sizeStr = formatSize(fileStat.size);
408
+ if (fileSize > MAX_IMAGE_SIZE) {
409
+ const sizeStr = formatSize(fileSize);
391
410
  const maxStr = formatSize(MAX_IMAGE_SIZE);
392
411
  content = [
393
412
  {
@@ -397,13 +416,30 @@ export function createReadTool(cwd: string): AgentTool<typeof readSchema> {
397
416
  ];
398
417
  } else {
399
418
  // Read as image (binary)
400
- const buffer = await readFile(absolutePath);
401
- const base64 = buffer.toString("base64");
419
+ const file = Bun.file(absolutePath);
420
+ const buffer = await file.arrayBuffer();
421
+ const base64 = Buffer.from(buffer).toString("base64");
422
+
423
+ if (autoResizeImages) {
424
+ // Resize image if needed
425
+ const resized = await resizeImage({ type: "image", data: base64, mimeType });
426
+ const dimensionNote = formatDimensionNote(resized);
427
+
428
+ let textNote = `Read image file [${resized.mimeType}]`;
429
+ if (dimensionNote) {
430
+ textNote += `\n${dimensionNote}`;
431
+ }
402
432
 
403
- content = [
404
- { type: "text", text: `Read image file [${mimeType}]` },
405
- { type: "image", data: base64, mimeType },
406
- ];
433
+ content = [
434
+ { type: "text", text: textNote },
435
+ { type: "image", data: resized.data, mimeType: resized.mimeType },
436
+ ];
437
+ } else {
438
+ content = [
439
+ { type: "text", text: `Read image file [${mimeType}]` },
440
+ { type: "image", data: base64, mimeType },
441
+ ];
442
+ }
407
443
  }
408
444
  } else if (CONVERTIBLE_EXTENSIONS.has(ext)) {
409
445
  // Convert document via markitdown
@@ -431,7 +467,8 @@ export function createReadTool(cwd: string): AgentTool<typeof readSchema> {
431
467
  }
432
468
  } else {
433
469
  // Read as text
434
- const textContent = await readFile(absolutePath, "utf-8");
470
+ const file = Bun.file(absolutePath);
471
+ const textContent = await file.text();
435
472
  const allLines = textContent.split("\n");
436
473
  const totalFileLines = allLines.length;
437
474