Bläddra i källkod

feat(grep): add hybrid local grep override

Add a ripgrep-first grep override with managed backend resolution, hybrid mtime ordering, and explicit GNU grep fallback so slim gets a faster, more capable local search path while keeping degraded behavior and docs aligned.
dhaern 4 månader sedan
förälder
incheckning
ae0c800a31

+ 1 - 1
README.md

@@ -404,7 +404,7 @@ If any agent fails to respond, check your provider authentication and config fil
 |-----|----------|
 | **[Skills](docs/skills.md)** | `simplify`, `agent-browser`, `cartography` — assignment syntax |
 | **[MCPs](docs/mcps.md)** | `websearch`, `context7`, `grep_app` — permissions per agent |
-| **[Tools](docs/tools.md)** | Background tasks, LSP, code search, formatters |
+| **[Tools](docs/tools.md)** | Background tasks, LSP, local `grep`, AST-grep, formatters |
 | **[Configuration](docs/configuration.md)** | Config files, prompt overriding, JSONC, full option reference |
 
 Slim only intercepts `apply_patch` before native execution. It rewrites recoverable stale patches, canonizes safe tolerant matches against the real file when unicode/trim drift is the only mismatch, keeps the authored `new_lines` bytes intact, preserves existing file EOL/final-newline state for updates, validates malformed patches strictly before helper execution, uses a conservative bounded LCS fallback, supports sequential `Update File` hunks on the same path through accumulated helper state, and blocks `apply_patch` before the native tool runs if any patch path falls outside the allowed root/worktree. This rescue does not extend to `edit` or `write`.

+ 3 - 2
codemap.md

@@ -54,7 +54,8 @@ The plugin integrates with OpenCode to provide:
 | `src/hooks/foreground-fallback/` | Rate-limit fallback manager for interactive sessions. | [View Map](src/hooks/foreground-fallback/codemap.md) |
 | `src/hooks/json-error-recovery/` | JSON parse error detection and recovery helpers. | [View Map](src/hooks/json-error-recovery/codemap.md) |
 | `src/mcp/` | Built-in MCP registry and config types for remote connectors. | [View Map](src/mcp/codemap.md) |
-| `src/tools/` | Tool registry plus LSP, AST-grep, and background task implementations. | [View Map](src/tools/codemap.md) |
+| `src/tools/` | Tool registry plus local grep, LSP, AST-grep, and background task implementations. | [View Map](src/tools/codemap.md) |
+| `src/tools/grep/` | ripgrep-first local grep override with managed backend resolution, hybrid mtime strategy, and GNU grep fallback. | [View Map](src/tools/grep/codemap.md) |
 | `src/tools/ast-grep/` | AST-grep CLI discovery, execution, and tool definitions. | [View Map](src/tools/ast-grep/codemap.md) |
 | `src/tools/lsp/` | LSP client stack and tool surface for definitions, diagnostics, and rename. | [View Map](src/tools/lsp/codemap.md) |
 | `src/utils/` | Shared helpers for tmux, environment variables, internal initiation, and config. | [View Map](src/utils/codemap.md) |
@@ -107,7 +108,7 @@ Return plugin object with:
    - MCP availability and permissions
 
 4. **Tools** (`src/tools/`)
-   - Code search (grep, AST-grep)
+   - Code search via local `grep` override (ripgrep-first with managed install/fallback) and AST-grep
    - LSP integration (diagnostics, references, rename)
    - Background task orchestration
 

+ 1 - 1
docs/quick-reference.md

@@ -24,7 +24,7 @@
 |-----|----------|
 | [Skills](skills.md) | `simplify`, `agent-browser`, `cartography` — skills assignment syntax |
 | [MCPs](mcps.md) | `websearch`, `context7`, `grep_app` — permissions per agent, global disable |
-| [Tools](tools.md) | Background tasks, LSP, code search (`ast_grep`), formatters |
+| [Tools](tools.md) | Background tasks, LSP, code search (`grep`, `ast_grep`), formatters |
 | [Configuration](configuration.md) | Config files, prompt overriding, JSONC, full option reference table |
 
 ## 💡 Author's Setup

+ 20 - 1
docs/tools.md

@@ -55,10 +55,29 @@ Fast, structural code search and refactoring — more powerful than plain text g
 
 | Tool | Description |
 |------|-------------|
-| `grep` | Fast content search using ripgrep |
+| `grep` | ripgrep-first local search with streaming results, context, globs, file-type filters, `sort_by=mtime`, managed `rg` install-on-miss, and GNU grep fallback |
 | `ast_grep_search` | AST-aware code pattern matching across 25 languages |
 | `ast_grep_replace` | AST-aware code refactoring with dry-run support |
 
+### `grep`
+
+The local `grep` override is the default text/code search tool exposed by slim.
+
+Key behavior:
+
+- Uses **system `rg` first** when available
+- Falls back to **managed ripgrep** from user cache
+- Can **install latest stable ripgrep on miss**
+- Uses **GNU grep** only as a last degraded fallback
+- Supports `content`, `files_with_matches`, and `count`
+- Supports advanced options such as smart case, fixed strings, invert match, asymmetric context, file globs, file types, hidden-file control, and `sort_by=mtime`
+
+Notes:
+
+- `sort_by=mtime` uses a hybrid strategy internally, not a single direct `rg` call.
+- Non-UTF8 paths are rendered with a stable visible discriminator like `[bytes:base64:...]`.
+- GNU grep fallback is warning-driven and intentionally degraded rather than pretending to match ripgrep perfectly.
+
 `ast_grep` understands code structure, so it can find patterns like "all arrow functions that return a JSX element" rather than relying on exact text matching.
 
 ---

+ 1 - 1
src/agents/codemap.md

@@ -60,7 +60,7 @@ All agents follow a consistent factory pattern:
 
 | Agent | Primary Focus | Tools | Constraints | Temperature |
 |-------|--------------|-------|-------------|-------------|
-| Explorer | Codebase navigation | grep, glob, ast_grep_search | Read-only, parallel | 0.1 |
+| Explorer | Codebase navigation | grep, glob, ast_grep_search | `grep` is the local ripgrep-based override with extended filters/context/sorting; read-only, parallel | 0.1 |
 | Librarian | External docs | context7, grep_app, websearch | Evidence-based, citations required | 0.1 |
 | Oracle | Architecture guidance | Analysis tools, code review | Read-only, advisory | 0.1 |
 | Designer | UI/UX implementation | Tailwind, CSS, animations | Visual excellence priority | 0.7 |

+ 2 - 2
src/codemap.md

@@ -9,11 +9,11 @@
 - Agent creation follows explicit factories (`agents/index.ts`, per-agent creators under `agents/`) with override/permission helpers (`config/utils.ts`, `cli/skills.ts`) so defaults live in `config/constants.ts`, prompts can be swapped via `config/loader.ts`, and variant labels propagate through `utils/agent-variant.ts`.
 - Background tooling composes `BackgroundTaskManager`, `TmuxSessionManager`, and `createBackgroundTools` (which uses `tool` with Zod schemas) to provide async/sync task launches plus cancel/output helpers; polling/prompt flow lives in `tools/background.ts` while TMUX lifecycle uses `utils/tmux.ts` to spawn/close panes and reapply layouts.
 - Hooks are isolated (`hooks/auto-update-checker`, `phase-reminder`, `post-file-tool-nudge`) and exported via `hooks/index.ts`, so the plugin simply registers them via the `event`, `experimental.chat.system.transform`, `experimental.chat.messages.transform`, and `tool.execute.after` hooks defined in `index.ts`.
-- Supplemental tools (`tools/grep`, `tools/lsp`, `tools/quota`) bundle ripgrep, LSP helpers, and Antigravity quota calls behind the OpenCode `tool` interface and are mounted in `index.ts` alongside background/task tools.
+- Supplemental tools (`tools/grep`, `tools/lsp`, `tools/quota`) bundle a ripgrep-first local search stack, LSP helpers, and Antigravity quota calls behind the OpenCode `tool` interface and are mounted in `index.ts` alongside background/task tools. The grep module includes managed ripgrep resolution/install-on-miss, explicit GNU grep fallback, streaming parsers, and a dedicated `mtime` hybrid strategy.
 
 ## Flow
 - Startup: `index.ts` calls `loadPluginConfig` (user + project JSON + presets) to build a `PluginConfig`, passes it to `getAgentConfigs` (which uses `createAgents`, agent factories, `loadAgentPrompt`, and `getAgentMcpList`) and to `BackgroundTaskManager`/`TmuxSessionManager`/`createBackgroundTools` so the in-memory state matches user overrides.
-- Plugin registration: `index.ts` registers agents, the tool map (background/task, `grep`, `ast_grep_*`, `lsp_*`, `antigravity_quota`), MCP definitions (`createBuiltinMcps`), and hooks (`createAutoUpdateCheckerHook`, `createPhaseReminderHook`, `createPostReadNudgeHook`); configuration hook merges those values back into the OpenCode config (default agent, permission rules parsed from `config/agent-mcps`, and MCP access policies).
+- Plugin registration: `index.ts` registers agents, the tool map (background/task, local `grep`, `ast_grep_*`, `lsp_*`, `antigravity_quota`), MCP definitions (`createBuiltinMcps`), and hooks (`createAutoUpdateCheckerHook`, `createPhaseReminderHook`, `createPostReadNudgeHook`); configuration hook merges those values back into the OpenCode config (default agent, permission rules parsed from `config/agent-mcps`, and MCP access policies).
 - Runtime: `BackgroundTaskManager.launch` spins up sessions and prompts agents via the OpenCode client, `pollTask`/`pollSession` watch for idle status before resolving results, while `TmuxSessionManager` observes `session.created` events to spawn panes via `utils/tmux` and close them when sessions idle or time out; tool hooks prevent recursion by toggling `background_task/task` permission when sending prompts.
 - CLI flow: `cli/install.ts` parses flags, optionally asks interactive prompts, checks OpenCode installation, adds plugin entries via `cli/config-manager.ts`, disables default agents, writes the lite config (`cli/config-io.ts`), and installs skills (`cli/skills.ts`, `cli/custom-skills.ts`).
 

+ 3 - 0
src/index.ts

@@ -25,6 +25,7 @@ import {
   ast_grep_search,
   createBackgroundTools,
   createCouncilTool,
+  createGrepTool,
   createWebfetchTool,
   lsp_diagnostics,
   lsp_find_references,
@@ -128,6 +129,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     : {};
 
   const mcps = createBuiltinMcps(config.disabled_mcps, config.websearch);
+  const grep = createGrepTool(ctx);
   const webfetch = createWebfetchTool(ctx);
 
   // Initialize MultiplexerSessionManager to handle OpenCode's built-in Task tool sessions
@@ -193,6 +195,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     tool: {
       ...backgroundTools,
       ...councilTools,
+      grep,
       webfetch,
       ...todoContinuationHook.tool,
       lsp_goto_definition,

+ 82 - 7
src/tools/codemap.md

@@ -2,11 +2,12 @@
 
 ## Responsibility
 
-The `src/tools/` directory provides the core tool implementations for the oh-my-opencode-slim plugin. It exposes three main categories of tools:
+The `src/tools/` directory provides the core tool implementations for the oh-my-opencode-slim plugin. It exposes four main categories of tools:
 
 1. **AST-grep** - AST-aware structural code search and replacement across 25+ languages
-2. **LSP** - Language Server Protocol integration for code intelligence (definition, references, diagnostics, rename)
-3. **Background Tasks** - Fire-and-forget agent task management with automatic notification
+2. **grep** - ripgrep-first local search with managed binary resolution, hybrid `mtime` ordering, and GNU grep fallback
+3. **LSP** - Language Server Protocol integration for code intelligence (definition, references, diagnostics, rename)
+4. **Background Tasks** - Fire-and-forget agent task management with automatic notification
 
 These tools are consumed by the OpenCode plugin system and exposed to AI agents for code navigation, analysis, and modification tasks.
 
@@ -20,6 +21,21 @@ These tools are consumed by the OpenCode plugin system and exposed to AI agents
 src/tools/
 ├── index.ts              # Central export point
 ├── background.ts         # Background task tools (3 tools)
+├── grep/
+│   ├── tool.ts           # Tool wrapper: normalize -> ask -> run -> format -> metadata
+│   ├── runtime.ts        # Process lifecycle, timeout/cancel, retries, semaphore
+│   ├── direct.ts         # Streaming rg executors for hot path modes
+│   ├── mtime.ts          # Hybrid discovery/sort/replay for sort_by=mtime
+│   ├── resolver.ts       # system rg -> managed rg -> install-on-miss -> GNU grep
+│   ├── fallback.ts       # Final GNU grep fallback backend
+│   ├── downloader.ts     # Managed ripgrep installer in user cache
+│   ├── normalize.ts      # Input normalization and permission-safe path resolution
+│   ├── rg-args.ts        # ripgrep argv builder
+│   ├── json-stream.ts    # Incremental JSON/NUL stream parsers
+│   ├── aggregate.ts      # Content collector with context drain
+│   ├── format.ts         # Human-readable output rendering
+│   ├── path-utils.ts     # Display paths and non-UTF8-safe byte identities
+│   └── codemap.md        # Detailed grep module map
 ├── ast-grep/
 │   ├── cli.ts            # CLI execution, path resolution, binary download
 │   ├── index.ts          # Module re-exports
@@ -57,20 +73,29 @@ The ast-grep module uses a CLI execution pattern:
 - **downloader.ts**: Binary auto-download for missing dependencies
 - **utils.ts**: Output formatting and truncation handling
 
-#### 3. Connection Pooling (LSP)
+#### 3. ripgrep-first Search Layer (grep)
+The grep module isolates execution policy into dedicated phases:
+- **normalize.ts**: canonical path normalization, realpath scope, permission patterns
+- **resolver.ts**: backend selection (`system rg -> managed rg -> install latest stable rg on miss -> GNU grep`)
+- **direct.ts**: streaming rg hot path with early-stop by visible results
+- **mtime.ts**: hybrid `mtime` discovery/sort/replay strategy
+- **fallback.ts**: explicit GNU grep-only degraded backend
+- **format.ts/path-utils.ts**: stable display for ripgrep-native paths/content, including non-UTF8-safe rendering via `bytes:base64:...`; GNU grep fallback remains degraded and may decode or skip non-UTF8 data instead of preserving byte-stable identities
+
+#### 4. Connection Pooling (LSP)
 The LSP module implements a singleton `LSPServerManager` with:
 - **Connection pooling**: Reuse LSP clients per workspace root (key: `root::serverId`)
 - **Reference counting**: Track active usage via `refCount`, increment on acquire, decrement on release
 - **Idle cleanup**: Auto-shutdown after 5 minutes of inactivity (check every 60s)
 - **Initialization tracking**: Prevent concurrent initialization races via `initPromise`
 
-#### 4. Safety Limits
+#### 5. Safety Limits
 All tools enforce strict safety limits:
-- **Timeout**: 300s (ast-grep, LSP initialization)
+- **Timeout**: tool-specific guardrails (grep defaults to 80s/max 140s; ast-grep/LSP keep their own limits)
 - **Output size**: 1MB (ast-grep)
 - **Match limits**: 500 matches (ast-grep), 200 diagnostics (LSP), 200 references (LSP)
 
-#### 5. Error Handling
+#### 6. Error Handling
 - Clear error messages with installation hints for missing binaries
 - Timeout handling with process cleanup
 - Truncation detection and reporting with reason codes
@@ -82,6 +107,32 @@ All tools enforce strict safety limits:
 
 ### AST-grep Tool Flow
 
+```
+
+### grep Tool Flow
+
+```text
+User Request (grep)
+    ↓
+tool.ts
+    ↓
+normalizeGrepInput()
+    ↓
+ctx.ask(permissionPatterns)
+    ↓
+runRipgrep()
+    ├─→ resolveGrepCliWithAutoInstall()
+    │   ├─→ system rg
+    │   ├─→ managed rg
+    │   ├─→ latest stable rg install-on-miss
+    │   └─→ GNU grep fallback
+    ├─→ direct.ts for normal path
+    ├─→ mtime.ts for sort_by=mtime
+    └─→ fallback.ts for GNU grep
+    ↓
+formatGrepResult()
+    ↓
+Return text output + structured metadata
 ```
 User Request (ast_grep_search or ast_grep_replace)
@@ -229,6 +280,7 @@ All tools are exported from `src/tools/index.ts`:
 ```typescript
 export { ast_grep_replace, ast_grep_search } from './ast-grep';
 export { createBackgroundTools } from './background';
+export { createGrepTool } from './grep';
 export {
   lsp_diagnostics,
   lsp_find_references,
@@ -258,6 +310,12 @@ export {
 
 ### Binary Management
 
+#### grep (grep/downloader.ts + grep/resolver.ts)
+- **Backend policy**: system rg → managed cached rg → latest stable rg install-on-miss → GNU grep
+- **Managed install location**: user cache under `oh-my-opencode-slim/grep/bin`
+- **Validation**: downloaded `rg` is validated with `--version` before final rename
+- **Fallback policy**: GNU grep is explicit and degraded, not treated as ripgrep-equivalent
+
 #### AST-grep (ast-grep/downloader.ts)
 - **Version**: 0.40.0 (synced with @ast-grep/cli package)
 - **Platforms**: darwin-arm64, darwin-x64, linux-arm64, linux-x64, win32-x64, win32-arm64, win32-ia32
@@ -271,6 +329,7 @@ export {
 - **Output truncation**: Prevent memory issues with large outputs
 - **Timeout enforcement**: All subprocess operations have timeouts
 - **Caching**: CLI paths cached to avoid repeated filesystem checks
+- **Streaming search**: grep hot path parses rg output incrementally and kills the process early when enough visible results have been collected
 - **Background tasks**: Fire-and-forget pattern for long-running operations
 
 ---
@@ -281,6 +340,22 @@ export {
 - **index.ts**: Central export point for all tools
 - **background.ts**: Background task management (3 tools: background_task, background_output, background_cancel)
 
+### grep/
+- **codemap.md**: Detailed grep architecture map
+- **tool.ts**: Tool wrapper and metadata emission
+- **runtime.ts**: Process lifecycle helpers
+- **direct.ts**: rg hot path executors
+- **mtime.ts**: hybrid `sort_by=mtime` strategy
+- **resolver.ts**: backend discovery and install-on-miss routing
+- **downloader.ts**: latest-stable ripgrep installer
+- **fallback.ts**: GNU grep degraded backend
+- **normalize.ts**: canonical input normalization
+- **rg-args.ts**: ripgrep argument builder
+- **json-stream.ts**: JSON/NUL stream parsers
+- **aggregate.ts**: content collector with context drain
+- **format.ts**: final human rendering
+- **path-utils.ts**: path display and byte-identity helpers
+
 ### ast-grep/
 - **index.ts**: Re-exports ast-grep module and types
 - **cli.ts**: `runSg()`, `getAstGrepPath()`, `startBackgroundInit()`, `isCliAvailable()`, `ensureCliAvailable()` - CLI execution layer

+ 242 - 0
src/tools/grep/aggregate.test.ts

@@ -0,0 +1,242 @@
+/// <reference types="bun-types" />
+import { describe, expect, test } from 'bun:test';
+import { GrepAggregator } from './aggregate';
+import {
+  consumeNullCountPairs,
+  consumeNullItems,
+  consumeRgJsonStream,
+} from './json-stream';
+import { createTempTracker, createTextStream } from './test-helpers';
+
+describe('tools/grep/aggregate', () => {
+  const temps = createTempTracker();
+
+  test('aggregates asymmetric before/after context independently', () => {
+    const repoDir = temps.createRepo();
+    const aggregator = new GrepAggregator({
+      cwd: repoDir,
+      worktree: repoDir,
+      maxResults: 10,
+      beforeContext: 1,
+      afterContext: 2,
+    });
+
+    const filePath = 'src/example.ts';
+
+    aggregator.consume({
+      type: 'context',
+      data: {
+        path: { text: filePath },
+        lines: { text: 'line 1\n' },
+        line_number: 1,
+      },
+    });
+    aggregator.consume({
+      type: 'context',
+      data: {
+        path: { text: filePath },
+        lines: { text: 'line 2\n' },
+        line_number: 2,
+      },
+    });
+    aggregator.consume({
+      type: 'match',
+      data: {
+        path: { text: filePath },
+        lines: { text: 'line 3 createTool\n' },
+        line_number: 3,
+        submatches: [
+          {
+            match: { text: 'createTool' },
+            start: 7,
+            end: 17,
+          },
+        ],
+      },
+    });
+    aggregator.consume({
+      type: 'context',
+      data: {
+        path: { text: filePath },
+        lines: { text: 'line 4\n' },
+        line_number: 4,
+      },
+    });
+    aggregator.consume({
+      type: 'context',
+      data: {
+        path: { text: filePath },
+        lines: { text: 'line 5\n' },
+        line_number: 5,
+      },
+    });
+    aggregator.consume({
+      type: 'context',
+      data: {
+        path: { text: filePath },
+        lines: { text: 'line 6\n' },
+        line_number: 6,
+      },
+    });
+
+    const snapshot = aggregator.snapshot();
+    expect(snapshot.files).toHaveLength(1);
+    expect(snapshot.files[0]?.matches[0]?.before).toEqual([
+      {
+        lineNumber: 2,
+        text: 'line 2',
+      },
+    ]);
+    expect(snapshot.files[0]?.matches[0]?.after).toEqual([
+      {
+        lineNumber: 4,
+        text: 'line 4',
+      },
+      {
+        lineNumber: 5,
+        text: 'line 5',
+      },
+    ]);
+  });
+
+  test('keeps adjacent post-limit matches as after-context while draining', () => {
+    const repoDir = temps.createRepo();
+    const aggregator = new GrepAggregator({
+      cwd: repoDir,
+      worktree: repoDir,
+      maxResults: 1,
+      beforeContext: 0,
+      afterContext: 2,
+    });
+
+    const filePath = 'src/example.ts';
+
+    aggregator.consume({
+      type: 'match',
+      data: {
+        path: { text: filePath },
+        lines: { text: 'needle one\n' },
+        line_number: 1,
+        submatches: [{ match: { text: 'needle' }, start: 0, end: 6 }],
+      },
+    });
+    aggregator.consume({
+      type: 'match',
+      data: {
+        path: { text: filePath },
+        lines: { text: 'needle two\n' },
+        line_number: 2,
+        submatches: [{ match: { text: 'needle' }, start: 0, end: 6 }],
+      },
+    });
+    aggregator.consume({
+      type: 'context',
+      data: {
+        path: { text: filePath },
+        lines: { text: 'after three\n' },
+        line_number: 3,
+      },
+    });
+
+    const snapshot = aggregator.snapshot();
+    expect(snapshot.totalMatches).toBe(1);
+    expect(snapshot.files[0]?.matches[0]?.after).toEqual([
+      { lineNumber: 2, text: 'needle two' },
+      { lineNumber: 3, text: 'after three' },
+    ]);
+  });
+
+  test('preserves a final blank line inside multiline matches', () => {
+    const repoDir = temps.createRepo();
+    const aggregator = new GrepAggregator({
+      cwd: repoDir,
+      worktree: repoDir,
+      maxResults: 5,
+      beforeContext: 0,
+      afterContext: 0,
+    });
+
+    aggregator.consume({
+      type: 'match',
+      data: {
+        path: { text: 'src/example.ts' },
+        lines: { text: 'foo\n\n' },
+        line_number: 1,
+        submatches: [{ match: { text: 'foo' }, start: 0, end: 3 }],
+      },
+    });
+
+    const snapshot = aggregator.snapshot();
+    expect(snapshot.files[0]?.matches[0]?.lineText).toBe('foo\n');
+  });
+
+  test.each([
+    {
+      name: 'consumeNullItems drops incomplete trailing filenames',
+      run: async () => {
+        const items: string[] = [];
+        await consumeNullItems(createTextStream(['alpha\0beta']), (item) => {
+          items.push(item);
+          return true;
+        });
+        expect(items).toEqual(['alpha']);
+      },
+    },
+    {
+      name: 'consumeNullItems preserves carriage returns inside valid POSIX paths',
+      run: async () => {
+        const items: string[] = [];
+        await consumeNullItems(createTextStream(['alpha\r\0']), (item) => {
+          items.push(item);
+          return true;
+        });
+        expect(items).toEqual(['alpha\r']);
+      },
+    },
+    {
+      name: 'consumeNullCountPairs drops incomplete trailing pairs and invalid counts',
+      run: async () => {
+        const pairs: Array<[string, string]> = [];
+        await consumeNullCountPairs(
+          createTextStream(['alpha\x0012\n', 'beta\x003oops']),
+          (filePath, countText) => {
+            pairs.push([filePath, countText]);
+            return true;
+          },
+        );
+        expect(pairs).toEqual([['alpha', '12']]);
+      },
+    },
+    {
+      name: 'consumeNullCountPairs preserves carriage returns in paths',
+      run: async () => {
+        const pairs: Array<[string, string]> = [];
+        await consumeNullCountPairs(
+          createTextStream(['alpha\r\x0012\n']),
+          (filePath, countText) => {
+            pairs.push([filePath, countText]);
+            return true;
+          },
+        );
+        expect(pairs).toEqual([['alpha\r', '12']]);
+      },
+    },
+  ])('$name', async ({ run }) => {
+    await run();
+  });
+
+  test('consumeRgJsonStream respects false returned by trailing callback', async () => {
+    const events: string[] = [];
+    await consumeRgJsonStream(
+      createTextStream([
+        '{"type":"match","data":{"path":{"text":"a"},"lines":{"text":"x"},"line_number":1,"submatches":[]}}',
+      ]),
+      (event) => {
+        events.push(event.type);
+        return false;
+      },
+    );
+
+    expect(events).toEqual(['match']);
+  });
+});

+ 369 - 0
src/tools/grep/aggregate.ts

@@ -0,0 +1,369 @@
+import { CONTEXT_BUFFER_MULTIPLIER } from './constants';
+import { decodeRgPayload } from './json-stream';
+import {
+  buildPathFromBytes,
+  getDisplayPath,
+  normalizeDisplayText,
+  resolveAbsolutePath,
+} from './path-utils';
+import type {
+  GrepContextLine,
+  GrepFileMatch,
+  GrepMatch,
+  GrepSummaryData,
+  NormalizedGrepInput,
+  RgContextEvent,
+  RgEndEvent,
+  RgJsonEvent,
+  RgMatchEvent,
+  RgSummaryEvent,
+} from './types';
+
+interface GrepAggregateSnapshot {
+  files: GrepFileMatch[];
+  totalMatches: number;
+  totalFiles: number;
+  limitReached: boolean;
+  summary?: GrepSummaryData;
+}
+
+interface InternalFileState extends GrepFileMatch {
+  beforeBuffer: GrepContextLine[];
+}
+
+interface ResolvedPathInfo {
+  absolutePath: string;
+  file: string;
+  pathKey: string;
+  replayPath?: string;
+  nonUtf8Path?: boolean;
+}
+
+function trimLineEnd(text: string | undefined): string {
+  if (!text) {
+    return '';
+  }
+
+  const normalized = normalizeDisplayText(text);
+  return normalized.endsWith('\n') ? normalized.slice(0, -1) : normalized;
+}
+
+function pushUniqueLineKeepingLast(
+  target: GrepContextLine[],
+  line: GrepContextLine,
+  maxItems: number,
+): void {
+  const last = target[target.length - 1];
+  if (last && last.lineNumber === line.lineNumber && last.text === line.text) {
+    return;
+  }
+
+  target.push(line);
+
+  if (target.length > maxItems) {
+    target.splice(0, target.length - maxItems);
+  }
+}
+
+function pushUniqueLineKeepingFirst(
+  target: GrepContextLine[],
+  line: GrepContextLine,
+  maxItems: number,
+): void {
+  const last = target[target.length - 1];
+  if (last && last.lineNumber === line.lineNumber && last.text === line.text) {
+    return;
+  }
+
+  if (target.length >= maxItems) {
+    return;
+  }
+
+  target.push(line);
+}
+
+export class GrepAggregator {
+  private readonly files = new Map<string, InternalFileState>();
+  private totalMatches = 0;
+  private limitReached = false;
+  private acceptingMatches = true;
+  private stopReady = false;
+  private drainPath?: string;
+  private drainKey?: string;
+  private summary?: GrepSummaryData;
+
+  constructor(
+    private readonly input: Pick<
+      NormalizedGrepInput,
+      'afterContext' | 'beforeContext' | 'cwd' | 'maxResults' | 'worktree'
+    >,
+  ) {}
+
+  consume(event: RgJsonEvent): void {
+    switch (event.type) {
+      case 'match':
+        this.consumeMatch(event);
+        break;
+      case 'context':
+        this.consumeContext(event);
+        break;
+      case 'summary':
+        this.consumeSummary(event);
+        break;
+      case 'end':
+        this.consumeEnd(event);
+        break;
+      default:
+        break;
+    }
+  }
+
+  isFull(): boolean {
+    return this.stopReady;
+  }
+
+  snapshot(): GrepAggregateSnapshot {
+    const files = Array.from(this.files.values())
+      .filter((file) => file.matches.length > 0)
+      .map(({ beforeBuffer: _beforeBuffer, ...file }) => file);
+
+    return {
+      files,
+      totalMatches: this.totalMatches,
+      totalFiles: files.length,
+      limitReached: this.limitReached,
+      summary: this.summary,
+    };
+  }
+
+  private consumeMatch(event: RgMatchEvent): void {
+    const pathInfo = this.resolvePathInfo(event.data.path);
+    const absolutePath = pathInfo.absolutePath;
+
+    if (!this.acceptingMatches) {
+      this.limitReached = true;
+
+      if (
+        this.drainPath &&
+        absolutePath === this.drainPath &&
+        pathInfo.pathKey === this.drainKey &&
+        this.input.afterContext > 0
+      ) {
+        this.appendDrainMatchAsAfterContext(pathInfo.pathKey, event);
+        return;
+      }
+
+      this.stopReady = true;
+      return;
+    }
+
+    const fileState = this.getFileState(pathInfo);
+
+    if (this.totalMatches >= this.input.maxResults) {
+      this.limitReached = true;
+      return;
+    }
+
+    const before =
+      this.input.beforeContext > 0
+        ? fileState.beforeBuffer
+            .filter((line) => line.lineNumber < event.data.line_number)
+            .slice(-this.input.beforeContext)
+        : [];
+
+    const match: GrepMatch = {
+      lineNumber: event.data.line_number,
+      lineText: trimLineEnd(decodeRgPayload(event.data.lines)),
+      submatches: event.data.submatches.map((submatch) =>
+        decodeRgPayload(submatch.match),
+      ),
+      before,
+      after: [],
+    };
+
+    fileState.matches.push(match);
+    fileState.matchCount += 1;
+    fileState.beforeBuffer = [];
+    this.totalMatches += 1;
+
+    if (this.totalMatches >= this.input.maxResults) {
+      this.limitReached = true;
+      this.acceptingMatches = false;
+
+      if (this.input.afterContext <= 0) {
+        this.stopReady = true;
+        return;
+      }
+
+      this.drainPath = absolutePath;
+      this.drainKey = pathInfo.pathKey;
+    }
+  }
+
+  private appendDrainMatchAsAfterContext(
+    pathKey: string,
+    event: RgMatchEvent,
+  ): void {
+    const fileState = this.files.get(pathKey);
+    const lastMatch = fileState?.matches[fileState.matches.length - 1];
+    if (!fileState || !lastMatch) {
+      this.stopReady = true;
+      return;
+    }
+
+    pushUniqueLineKeepingFirst(
+      lastMatch.after,
+      {
+        lineNumber: event.data.line_number,
+        text: trimLineEnd(decodeRgPayload(event.data.lines)),
+      },
+      this.input.afterContext,
+    );
+
+    if (lastMatch.after.length >= this.input.afterContext) {
+      this.stopReady = true;
+    }
+  }
+
+  private consumeContext(event: RgContextEvent): void {
+    if (this.input.beforeContext <= 0 && this.input.afterContext <= 0) {
+      return;
+    }
+
+    const filePath = decodeRgPayload(event.data.path);
+    if (
+      (!filePath && !event.data.path?.bytes) ||
+      typeof event.data.line_number !== 'number'
+    ) {
+      return;
+    }
+
+    const pathInfo = this.resolvePathInfo(event.data.path);
+    const absolutePath = pathInfo.absolutePath;
+    const fileState = this.getFileState(pathInfo);
+    const contextLine = {
+      lineNumber: event.data.line_number,
+      text: trimLineEnd(decodeRgPayload(event.data.lines)),
+    };
+
+    if (this.input.beforeContext > 0) {
+      const bufferSize = Math.max(
+        this.input.beforeContext,
+        this.input.beforeContext * CONTEXT_BUFFER_MULTIPLIER,
+      );
+
+      pushUniqueLineKeepingLast(
+        fileState.beforeBuffer,
+        contextLine,
+        bufferSize,
+      );
+    }
+
+    const lastMatch = fileState.matches[fileState.matches.length - 1];
+    if (
+      lastMatch &&
+      this.input.afterContext > 0 &&
+      contextLine.lineNumber > lastMatch.lineNumber
+    ) {
+      pushUniqueLineKeepingFirst(
+        lastMatch.after,
+        contextLine,
+        this.input.afterContext,
+      );
+
+      if (
+        !this.acceptingMatches &&
+        this.drainPath === absolutePath &&
+        this.drainKey === pathInfo.pathKey &&
+        lastMatch.after.length >= this.input.afterContext
+      ) {
+        this.stopReady = true;
+      }
+    }
+  }
+
+  private consumeEnd(event: RgEndEvent): void {
+    if (this.acceptingMatches || !this.drainPath) {
+      return;
+    }
+
+    const absolutePath = event.data.path
+      ? this.resolvePathInfo(event.data.path).absolutePath
+      : undefined;
+    const pathKey = event.data.path
+      ? this.resolvePathInfo(event.data.path).pathKey
+      : undefined;
+    if (
+      !absolutePath ||
+      (absolutePath === this.drainPath && pathKey === this.drainKey)
+    ) {
+      this.stopReady = true;
+    }
+  }
+
+  private consumeSummary(event: RgSummaryEvent): void {
+    const elapsed = event.data.elapsed_total;
+    this.summary = {
+      elapsedTotalMs:
+        elapsed == null
+          ? undefined
+          : Math.round(elapsed.secs * 1000 + elapsed.nanos / 1_000_000),
+      elapsedTotalHuman: elapsed?.human,
+      stats: event.data.stats,
+    };
+  }
+
+  private getFileState(pathInfo: ResolvedPathInfo): InternalFileState {
+    let fileState = this.files.get(pathInfo.pathKey);
+    if (!fileState) {
+      fileState = {
+        file: pathInfo.file,
+        absolutePath: pathInfo.absolutePath,
+        replayPath: pathInfo.replayPath,
+        nonUtf8Path: pathInfo.nonUtf8Path,
+        pathKey: pathInfo.pathKey,
+        matchCount: 0,
+        matches: [],
+        beforeBuffer: [],
+      };
+      this.files.set(pathInfo.pathKey, fileState);
+    }
+
+    return fileState;
+  }
+
+  private resolvePathInfo(
+    payload:
+      | RgMatchEvent['data']['path']
+      | RgContextEvent['data']['path']
+      | RgEndEvent['data']['path'],
+  ): ResolvedPathInfo {
+    if (payload?.bytes) {
+      const pathInfo = buildPathFromBytes(
+        Buffer.from(payload.bytes, 'base64'),
+        this.input.cwd,
+        this.input.worktree,
+      );
+      return {
+        absolutePath: pathInfo.absolutePath,
+        file: pathInfo.displayPath,
+        pathKey: pathInfo.pathKey,
+        replayPath: pathInfo.replayPath,
+        nonUtf8Path: pathInfo.nonUtf8Path,
+      };
+    }
+
+    const absolutePath = this.resolveAbsolutePath(payload?.text ?? '');
+    return {
+      absolutePath,
+      file: getDisplayPath(absolutePath, this.input.worktree),
+      pathKey: `utf8:${absolutePath}`,
+      replayPath: absolutePath,
+      nonUtf8Path: false,
+    };
+  }
+
+  private resolveAbsolutePath(filePath: string): string {
+    return resolveAbsolutePath(filePath, this.input.cwd);
+  }
+}

+ 98 - 0
src/tools/grep/codemap.md

@@ -0,0 +1,98 @@
+# src/tools/grep/
+
+## Responsibility
+
+Implements the local `grep` override shipped by slim. The module provides a ripgrep-first search tool with OpenCode-compatible semantics, managed backend resolution, hybrid `mtime` ordering, and final GNU grep fallback.
+
+## Design
+
+### Module layout
+
+```text
+src/tools/grep/
+├── tool.ts         # Tool wrapper: normalize -> ask -> run -> format -> metadata
+├── schema.ts       # Public input schema
+├── types.ts        # Request/result/event types
+├── constants.ts    # Identity, defaults, hard caps, tool description
+├── normalize.ts    # Raw args -> NormalizedGrepInput, realpath/permission scope
+├── rg-args.ts      # ripgrep argv builder
+├── runtime.ts      # Process lifecycle, timeout/cancel, retries, semaphore
+├── direct.ts       # Streaming rg hot path for content/count/files modes
+├── mtime.ts        # Hybrid discover/sort/replay strategy for sort_by=mtime
+├── fallback.ts     # Final GNU grep fallback backend
+├── resolver.ts     # system rg -> managed rg -> install-on-miss -> GNU grep
+├── downloader.ts   # Managed ripgrep installer in user cache
+├── json-stream.ts  # JSON/NUL stream parsers
+├── aggregate.ts    # Content collector with context drain
+├── result-utils.ts # Result shaping and truncation helpers
+├── path-utils.ts   # Display paths, byte identities, non-UTF8-safe rendering
+├── summary.ts      # Human summary builders
+├── format.ts       # Final human-readable output
+└── *.test.ts       # Focused subsystem suites
+```
+
+### Execution paths
+
+1. **Direct rg path**
+   - normal hot path
+   - streaming parse
+   - early-stop by visible limit
+
+2. **`mtime-hybrid`**
+   - discovery of matching files
+   - mtime sort
+   - replay in sorted order
+   - degrades to `mtime-fallback` when byte-paths are not safely orderable/replayable
+
+3. **GNU grep fallback**
+   - last-resort degraded backend
+   - explicit warning-driven behavior
+   - only entered when ripgrep cannot be provided
+   - non-UTF8 path/content fidelity is best-effort only because fallback parsing operates on decoded GNU grep output, not ripgrep byte payloads
+
+### Backend resolution policy
+
+The resolver follows this order:
+
+1. `system rg`
+2. managed cached `rg`
+3. install latest stable `rg` on miss
+4. `system GNU grep`
+
+Aborts during auto-install propagate cleanly and are not cached as permanent install failure.
+
+## Flow
+
+```text
+Tool call
+  ↓
+tool.ts
+  ↓ normalize.ts
+NormalizedGrepInput
+  ↓
+ctx.ask(permissionPatterns)
+  ↓
+runner.ts
+  ├─→ resolver.ts
+  ├─→ direct.ts
+  ├─→ mtime.ts
+  └─→ fallback.ts
+  ↓
+format.ts
+  ↓
+structured metadata + human output
+```
+
+## Integration
+
+- Registered by `src/index.ts` as the local `grep` override.
+- Exported from `src/tools/index.ts` and `src/tools/grep/index.ts`.
+- Uses `src/utils/zip-extractor.ts` for managed ripgrep archive extraction.
+- Does not patch OpenCode core.
+
+## Notes
+
+- The default path is optimized for LLM use: larger defaults, streaming parse, early-stop, and structured metadata.
+- Ripgrep-native non-UTF8 paths/content are rendered with stable `bytes:base64:...` identities when raw bytes are available.
+- GNU grep fallback is intentionally degraded for non-UTF8 data: it may decode with replacement characters or skip unparsable output, and it does not promise the same stable byte identity surface as the ripgrep path.
+- Rare compatibility paths are explicit and warning-driven instead of silently pretending to match ripgrep exactly.

+ 31 - 0
src/tools/grep/constants.ts

@@ -0,0 +1,31 @@
+export const GREP_TOOL_ID = 'grep';
+export const RG_BINARY = 'rg';
+export const GREP_BINARY = 'grep';
+
+export const GREP_DESCRIPTION = `A powerful local search tool built on ripgrep.
+
+Usage:
+- Compatible base args: pattern, path?, include?
+- Output modes: content, files_with_matches, count
+- Supports regex and fixed-string search, smart-case, PCRE2, invert_match, multiline, and multiline_dotall
+- Supports context, file type and glob filters, hidden files, symlink following, per-file max counts, max_filesize, and path or mtime sorting
+- For mtime sorting, the tool uses a hybrid strategy and may fall back to direct search with a warning when safe mtime ordering is not possible
+- Use this tool instead of shelling out to rg for code/content search in the workspace`;
+
+export const DEFAULT_GREP_TIMEOUT_MS = 80_000;
+export const MAX_GREP_TIMEOUT_MS = 140_000;
+
+export const DEFAULT_GREP_LIMIT = 500;
+export const MAX_GREP_LIMIT = 5_000;
+export const MAX_MTIME_DISCOVERY_FILES = 5_000;
+
+export const DEFAULT_GREP_CONTEXT = 0;
+export const MAX_GREP_CONTEXT = 20;
+
+export const DEFAULT_GREP_MAX_CONCURRENCY = 2;
+export const DEFAULT_GREP_RETRY_COUNT = 1;
+export const DEFAULT_GREP_RETRY_DELAY_MS = 150;
+
+export const CONTEXT_BUFFER_MULTIPLIER = 2;
+export const MAX_LINE_LENGTH = 2_000;
+export const MAX_STDERR_CHARS = 20_000_000;

+ 389 - 0
src/tools/grep/direct.ts

@@ -0,0 +1,389 @@
+import { GrepAggregator } from './aggregate';
+import {
+  consumeNullCountPairsBytes,
+  consumeNullItemsBytes,
+  consumeRgJsonStream,
+  readTextStream,
+} from './json-stream';
+import { buildPathFromBytes } from './path-utils';
+import type { ResolvedGrepCli } from './resolver';
+import {
+  applySuccessfulStderr,
+  createEmptyResult,
+  finalizeNonFatalExit,
+  hasVisibleResults,
+} from './result-utils';
+import { buildRgCommand } from './rg-args';
+import {
+  attachTerminationHandlers,
+  createFriendlySpawnError,
+  type GrepProcess,
+  getAbortKind,
+  isTransientFailure,
+  isTransientStderr,
+  killProcess,
+  RetryableRipgrepError,
+  spawnRipgrep,
+  type TerminationState,
+  toErrorMessage,
+  waitForExitAndStderr,
+} from './runtime';
+import type {
+  GrepFileMatch,
+  GrepSearchResult,
+  NormalizedGrepInput,
+} from './types';
+
+interface ContentState {
+  aggregator: GrepAggregator;
+  killedForLimit: boolean;
+}
+
+interface CountState {
+  files: GrepFileMatch[];
+  totalMatches: number;
+  limitReached: boolean;
+}
+
+interface FilesState {
+  files: GrepFileMatch[];
+  limitReached: boolean;
+  seen: Set<string>;
+}
+
+function buildFileMatch(
+  filePath: Uint8Array,
+  input: Pick<NormalizedGrepInput, 'cwd' | 'worktree'>,
+  matchCount: number,
+): GrepFileMatch | undefined {
+  if (filePath.length === 0) {
+    return undefined;
+  }
+
+  const pathInfo = buildPathFromBytes(filePath, input.cwd, input.worktree);
+  return {
+    file: pathInfo.displayPath,
+    absolutePath: pathInfo.absolutePath,
+    replayPath: pathInfo.replayPath,
+    nonUtf8Path: pathInfo.nonUtf8Path,
+    pathKey: pathInfo.pathKey,
+    matchCount,
+    matches: [],
+  };
+}
+
+function parseCountRecordBytes(
+  filePath: Uint8Array,
+  countText: string,
+  input: Pick<NormalizedGrepInput, 'cwd' | 'worktree'>,
+): GrepFileMatch | undefined {
+  if (!/^\d+$/.test(countText)) {
+    return undefined;
+  }
+
+  const count = Number.parseInt(countText, 10);
+  if (!Number.isFinite(count)) {
+    return undefined;
+  }
+
+  return buildFileMatch(filePath, input, count);
+}
+
+function buildFileMatchFromBytes(
+  filePath: Uint8Array,
+  input: Pick<NormalizedGrepInput, 'cwd' | 'worktree'>,
+): GrepFileMatch | undefined {
+  return buildFileMatch(filePath, input, 1);
+}
+
+function simpleIsStopped(
+  state: { limitReached: boolean },
+  termination: TerminationState,
+): boolean {
+  return termination.timedOut || termination.cancelled || state.limitReached;
+}
+
+async function executeMode<TState>(
+  input: NormalizedGrepInput,
+  signal: AbortSignal,
+  cli: ResolvedGrepCli,
+  options: {
+    init: () => TState;
+    consumeStdout: (
+      stdout: NodeJS.ReadableStream | ReadableStream<Uint8Array> | undefined,
+      proc: GrepProcess,
+      state: TState,
+    ) => Promise<void>;
+    buildResult: (
+      baseResult: GrepSearchResult,
+      state: TState,
+      termination: TerminationState,
+      exitCode: number,
+      stderr: string,
+    ) => GrepSearchResult;
+    isStopped: (state: TState, termination: TerminationState) => boolean;
+  },
+): Promise<GrepSearchResult> {
+  const command = buildRgCommand(input, cli.path);
+  const baseResult: GrepSearchResult = {
+    ...createEmptyResult(input, command),
+    backend: 'rg',
+  };
+
+  if (signal.aborted) {
+    return {
+      ...baseResult,
+      truncated: true,
+      timedOut: getAbortKind(signal) === 'timeout',
+      cancelled: getAbortKind(signal) !== 'timeout',
+    };
+  }
+
+  let proc: GrepProcess;
+  try {
+    proc = spawnRipgrep(command, input.cwd);
+  } catch (error) {
+    const friendlyMessage = createFriendlySpawnError(error, cli);
+    if (friendlyMessage) {
+      return {
+        ...baseResult,
+        error: friendlyMessage,
+      };
+    }
+
+    if (isTransientFailure(error)) {
+      throw new RetryableRipgrepError(toErrorMessage(error));
+    }
+
+    return {
+      ...baseResult,
+      error:
+        error instanceof Error
+          ? error.message
+          : `Failed to spawn ${cli.backend}`,
+    };
+  }
+
+  const state = options.init();
+  const termination = attachTerminationHandlers(proc, input.timeoutMs, signal);
+
+  try {
+    const stdout = proc.proc.stdout ?? undefined;
+    const stderrStream = proc.proc.stderr ?? undefined;
+    const stdoutPromise = options.consumeStdout(stdout, proc, state);
+    const stderrPromise = readTextStream(stderrStream);
+
+    let stdoutError: unknown;
+    try {
+      await stdoutPromise;
+    } catch (error) {
+      stdoutError = error;
+    }
+
+    const { exitCode, stderr } = await waitForExitAndStderr(
+      proc,
+      stderrPromise,
+    );
+    const result = options.buildResult(
+      baseResult,
+      state,
+      termination.state,
+      exitCode,
+      stderr.trim(),
+    );
+
+    if (stdoutError && !options.isStopped(state, termination.state)) {
+      if (isTransientFailure(stdoutError)) {
+        throw new RetryableRipgrepError(toErrorMessage(stdoutError));
+      }
+
+      if (hasVisibleResults(result)) {
+        result.truncated = true;
+        result.warnings.push(
+          `Partial output processing failure: ${toErrorMessage(stdoutError)}`,
+        );
+        return result;
+      }
+
+      result.error =
+        stdoutError instanceof Error
+          ? stdoutError.message
+          : 'Failed to process rg output';
+      return result;
+    }
+
+    applySuccessfulStderr(result, result.stderr, exitCode);
+
+    if (options.isStopped(state, termination.state)) {
+      return result;
+    }
+
+    const nonFatal = finalizeNonFatalExit(result, exitCode);
+    if (nonFatal) {
+      return nonFatal;
+    }
+
+    if (isTransientStderr(result.stderr)) {
+      throw new RetryableRipgrepError(result.stderr);
+    }
+
+    result.error = result.stderr || `rg exited with code ${String(exitCode)}`;
+    return result;
+  } finally {
+    termination.cleanup();
+  }
+}
+
+export async function executeContentLikeMode(
+  input: NormalizedGrepInput,
+  signal: AbortSignal,
+  cli: ResolvedGrepCli,
+): Promise<GrepSearchResult> {
+  return executeMode(input, signal, cli, {
+    init: (): ContentState => ({
+      aggregator: new GrepAggregator({
+        cwd: input.cwd,
+        worktree: input.worktree,
+        maxResults: input.maxResults,
+        beforeContext: input.beforeContext,
+        afterContext: input.afterContext,
+      }),
+      killedForLimit: false,
+    }),
+    consumeStdout: async (stdout, proc, state) =>
+      consumeRgJsonStream(stdout, (event) => {
+        state.aggregator.consume(event);
+
+        if (state.aggregator.isFull()) {
+          state.killedForLimit = true;
+          killProcess(proc);
+          return false;
+        }
+
+        return true;
+      }),
+    buildResult: (baseResult, state, termination, exitCode, stderr) => {
+      const snapshot = state.aggregator.snapshot();
+      return {
+        ...baseResult,
+        ...snapshot,
+        truncated:
+          snapshot.limitReached ||
+          state.killedForLimit ||
+          termination.timedOut ||
+          termination.cancelled,
+        limitReached: snapshot.limitReached || state.killedForLimit,
+        timedOut: termination.timedOut,
+        cancelled: termination.cancelled,
+        exitCode,
+        stderr,
+        summary: snapshot.summary,
+        warnings: [],
+      };
+    },
+    isStopped: (state, termination) =>
+      termination.timedOut ||
+      termination.cancelled ||
+      state.killedForLimit ||
+      state.aggregator.snapshot().limitReached,
+  });
+}
+
+export async function executeCountMode(
+  input: NormalizedGrepInput,
+  signal: AbortSignal,
+  cli: ResolvedGrepCli,
+): Promise<GrepSearchResult> {
+  return executeMode(input, signal, cli, {
+    init: (): CountState => ({
+      files: [],
+      totalMatches: 0,
+      limitReached: false,
+    }),
+    consumeStdout: async (stdout, proc, state) =>
+      consumeNullCountPairsBytes(stdout, (filePath, countText) => {
+        const file = parseCountRecordBytes(filePath, countText, input);
+        if (!file) {
+          return true;
+        }
+
+        state.files.push(file);
+        state.totalMatches += file.matchCount;
+
+        if (state.files.length >= input.maxResults) {
+          state.limitReached = true;
+          killProcess(proc);
+          return false;
+        }
+
+        return true;
+      }),
+    buildResult: (baseResult, state, termination, exitCode, stderr) => ({
+      ...baseResult,
+      files: state.files,
+      totalMatches: state.totalMatches,
+      totalFiles: state.files.length,
+      truncated:
+        state.limitReached || termination.timedOut || termination.cancelled,
+      limitReached: state.limitReached,
+      timedOut: termination.timedOut,
+      cancelled: termination.cancelled,
+      exitCode,
+      stderr,
+      warnings: [],
+    }),
+    isStopped: simpleIsStopped,
+  });
+}
+
+export async function executeFilesMode(
+  input: NormalizedGrepInput,
+  signal: AbortSignal,
+  cli: ResolvedGrepCli,
+): Promise<GrepSearchResult> {
+  return executeMode(input, signal, cli, {
+    init: (): FilesState => ({
+      files: [],
+      limitReached: false,
+      seen: new Set<string>(),
+    }),
+    consumeStdout: async (stdout, proc, state) =>
+      consumeNullItemsBytes(stdout, (filePath) => {
+        const file = buildFileMatchFromBytes(filePath, input);
+        if (!file) {
+          return true;
+        }
+
+        const seenKey = file.pathKey ?? file.absolutePath;
+        if (state.seen.has(seenKey)) {
+          return true;
+        }
+
+        state.seen.add(seenKey);
+        state.files.push(file);
+
+        if (state.files.length >= input.maxResults) {
+          state.limitReached = true;
+          killProcess(proc);
+          return false;
+        }
+
+        return true;
+      }),
+    buildResult: (baseResult, state, termination, exitCode, stderr) => ({
+      ...baseResult,
+      files: state.files,
+      totalMatches: state.files.length,
+      totalFiles: state.files.length,
+      truncated:
+        state.limitReached || termination.timedOut || termination.cancelled,
+      limitReached: state.limitReached,
+      timedOut: termination.timedOut,
+      cancelled: termination.cancelled,
+      exitCode,
+      stderr,
+      warnings: [],
+    }),
+    isStopped: simpleIsStopped,
+  });
+}

+ 426 - 0
src/tools/grep/downloader.ts

@@ -0,0 +1,426 @@
+import { spawnSync } from 'node:child_process';
+import {
+  chmodSync,
+  existsSync,
+  mkdirSync,
+  readdirSync,
+  renameSync,
+  rmSync,
+} from 'node:fs';
+import { writeFile } from 'node:fs/promises';
+import { homedir } from 'node:os';
+import { dirname, join } from 'node:path';
+import { sync as whichSync } from 'which';
+import { extractZip, getZipExtractionSupportError } from '../../utils';
+import { crossSpawn } from '../../utils/compat';
+
+interface RipgrepReleaseAsset {
+  name?: string;
+  browser_download_url?: string;
+}
+
+interface RipgrepReleaseResponse {
+  tag_name?: string;
+  assets?: RipgrepReleaseAsset[];
+}
+
+type ArchiveExtension = 'tar.gz' | 'zip';
+
+interface PlatformCandidate {
+  target: string;
+  extension: ArchiveExtension;
+}
+
+function createAbortError(): Error {
+  const error = new Error('ripgrep auto-install was aborted');
+  error.name = 'AbortError';
+  return error;
+}
+
+function throwIfAborted(signal?: AbortSignal): void {
+  if (signal?.aborted) {
+    throw createAbortError();
+  }
+}
+
+function hasExecutable(name: string): boolean {
+  try {
+    const resolved = whichSync(name, { nothrow: true });
+    return Array.isArray(resolved)
+      ? (resolved[0] ?? '').length > 0
+      : (resolved ?? '').length > 0;
+  } catch {
+    return false;
+  }
+}
+
+function getCacheBaseDir(): string {
+  if (process.platform === 'win32') {
+    const localAppData = process.env.LOCALAPPDATA || process.env.APPDATA;
+    return localAppData || join(homedir(), 'AppData', 'Local');
+  }
+
+  return process.env.XDG_CACHE_HOME || join(homedir(), '.cache');
+}
+
+export function getRipgrepCacheDir(): string {
+  return join(getCacheBaseDir(), 'oh-my-opencode-slim', 'grep', 'bin');
+}
+
+export function getRipgrepBinaryName(): string {
+  return process.platform === 'win32' ? 'rg.exe' : 'rg';
+}
+
+export function getInstalledRipgrepPath(): string | null {
+  const binaryPath = join(getRipgrepCacheDir(), getRipgrepBinaryName());
+  return existsSync(binaryPath) ? binaryPath : null;
+}
+
+function detectLinuxLibc(): 'gnu' | 'musl' {
+  const muslLoaders = [
+    '/lib/ld-musl-x86_64.so.1',
+    '/lib/ld-musl-aarch64.so.1',
+    '/usr/glibc-compat/lib/ld-musl-x86_64.so.1',
+    '/usr/glibc-compat/lib/ld-musl-aarch64.so.1',
+  ];
+
+  if (muslLoaders.some((candidate) => existsSync(candidate))) {
+    return 'musl';
+  }
+
+  try {
+    const result = spawnSync('ldd', ['--version'], {
+      stdio: ['ignore', 'pipe', 'pipe'],
+    });
+    const output =
+      `${result.stdout?.toString() ?? ''}\n${result.stderr?.toString() ?? ''}`.toLowerCase();
+
+    if (output.includes('musl')) {
+      return 'musl';
+    }
+  } catch {
+    // Ignore and fall back to gnu.
+  }
+
+  return 'gnu';
+}
+
+function getPlatformCandidates(): PlatformCandidate[] {
+  if (process.platform === 'darwin') {
+    if (process.arch === 'arm64') {
+      return [{ target: 'aarch64-apple-darwin', extension: 'tar.gz' }];
+    }
+
+    if (process.arch === 'x64') {
+      return [{ target: 'x86_64-apple-darwin', extension: 'tar.gz' }];
+    }
+
+    return [];
+  }
+
+  if (process.platform === 'win32') {
+    if (process.arch === 'arm64') {
+      return [{ target: 'aarch64-pc-windows-msvc', extension: 'zip' }];
+    }
+
+    if (process.arch === 'x64') {
+      return [{ target: 'x86_64-pc-windows-msvc', extension: 'zip' }];
+    }
+
+    return [];
+  }
+
+  if (process.platform === 'linux') {
+    const libc = detectLinuxLibc();
+
+    if (process.arch === 'arm64') {
+      return libc === 'musl'
+        ? [
+            { target: 'aarch64-unknown-linux-musl', extension: 'tar.gz' },
+            { target: 'aarch64-unknown-linux-gnu', extension: 'tar.gz' },
+          ]
+        : [
+            { target: 'aarch64-unknown-linux-gnu', extension: 'tar.gz' },
+            { target: 'aarch64-unknown-linux-musl', extension: 'tar.gz' },
+          ];
+    }
+
+    if (process.arch === 'x64') {
+      return libc === 'musl'
+        ? [
+            { target: 'x86_64-unknown-linux-musl', extension: 'tar.gz' },
+            { target: 'x86_64-unknown-linux-gnu', extension: 'tar.gz' },
+          ]
+        : [
+            { target: 'x86_64-unknown-linux-gnu', extension: 'tar.gz' },
+            { target: 'x86_64-unknown-linux-musl', extension: 'tar.gz' },
+          ];
+    }
+  }
+
+  return [];
+}
+
+function findBinaryRecursive(
+  directory: string,
+  binaryName: string,
+): string | null {
+  try {
+    const entries = readdirSync(directory, { withFileTypes: true });
+
+    for (const entry of entries) {
+      const entryPath = join(directory, entry.name);
+
+      if (entry.isFile() && entry.name === binaryName) {
+        return entryPath;
+      }
+
+      if (entry.isDirectory()) {
+        const nested = findBinaryRecursive(entryPath, binaryName);
+        if (nested) {
+          return nested;
+        }
+      }
+    }
+  } catch {
+    return null;
+  }
+
+  return null;
+}
+
+async function fetchLatestRelease(
+  signal?: AbortSignal,
+): Promise<RipgrepReleaseResponse> {
+  const response = await fetch(
+    'https://api.github.com/repos/BurntSushi/ripgrep/releases/latest',
+    {
+      headers: {
+        accept: 'application/vnd.github+json',
+        'user-agent': 'oh-my-opencode-slim',
+      },
+      redirect: 'follow',
+      signal,
+    },
+  );
+
+  if (!response.ok) {
+    throw new Error(
+      `Failed to resolve latest ripgrep release: HTTP ${response.status} ${response.statusText}`,
+    );
+  }
+
+  const payload = (await response.json()) as RipgrepReleaseResponse;
+  if (!payload.tag_name || !Array.isArray(payload.assets)) {
+    throw new Error('Latest ripgrep release metadata is incomplete.');
+  }
+
+  return payload;
+}
+
+function selectReleaseAsset(release: RipgrepReleaseResponse): {
+  asset: RipgrepReleaseAsset;
+  version: string;
+} {
+  const version = release.tag_name?.replace(/^v/i, '');
+  if (!version) {
+    throw new Error('Latest ripgrep release is missing a version tag.');
+  }
+
+  const assets = release.assets ?? [];
+  const candidates = getPlatformCandidates();
+
+  if (candidates.length === 0) {
+    throw new Error(
+      `Unsupported platform for ripgrep auto-install: ${process.platform}-${process.arch}`,
+    );
+  }
+
+  for (const candidate of candidates) {
+    const expectedName = `ripgrep-${version}-${candidate.target}.${candidate.extension}`;
+    const asset = assets.find((item) => item.name === expectedName);
+
+    if (asset?.browser_download_url) {
+      return { asset, version };
+    }
+  }
+
+  throw new Error(
+    `No ripgrep asset is available for ${process.platform}-${process.arch}.`,
+  );
+}
+
+async function downloadArchive(
+  url: string,
+  destinationPath: string,
+  signal?: AbortSignal,
+): Promise<void> {
+  const response = await fetch(url, { redirect: 'follow', signal });
+
+  if (!response.ok) {
+    throw new Error(
+      `Failed to download ripgrep archive: HTTP ${response.status} ${response.statusText}`,
+    );
+  }
+
+  const arrayBuffer = await response.arrayBuffer();
+  throwIfAborted(signal);
+  await writeFile(destinationPath, Buffer.from(arrayBuffer));
+}
+
+async function extractTarGz(
+  archivePath: string,
+  destinationDir: string,
+  signal?: AbortSignal,
+): Promise<void> {
+  throwIfAborted(signal);
+  const proc = crossSpawn(['tar', '-xzf', archivePath, '-C', destinationDir], {
+    stdout: 'ignore',
+    stderr: 'pipe',
+  });
+
+  const onAbort = () => {
+    try {
+      proc.kill();
+    } catch {
+      // Process may have already exited.
+    }
+  };
+
+  signal?.addEventListener('abort', onAbort, { once: true });
+
+  const exitCode = await proc.exited;
+  signal?.removeEventListener('abort', onAbort);
+
+  if (signal?.aborted) {
+    throw createAbortError();
+  }
+
+  if (exitCode !== 0) {
+    const stderr = await proc.stderr();
+    throw new Error(`ripgrep extraction failed (exit ${exitCode}): ${stderr}`);
+  }
+}
+
+async function extractArchive(
+  archivePath: string,
+  destinationDir: string,
+  extension: ArchiveExtension,
+  signal?: AbortSignal,
+): Promise<void> {
+  if (extension === 'zip') {
+    await extractZip(archivePath, destinationDir, signal);
+    return;
+  }
+
+  await extractTarGz(archivePath, destinationDir, signal);
+}
+
+function ensureExecutable(binaryPath: string): void {
+  if (process.platform !== 'win32') {
+    chmodSync(binaryPath, 0o755);
+  }
+}
+
+function validateInstalledBinary(
+  binaryPath: string,
+  signal?: AbortSignal,
+): void {
+  throwIfAborted(signal);
+  const result = spawnSync(binaryPath, ['--version'], {
+    stdio: ['ignore', 'pipe', 'pipe'],
+  });
+
+  throwIfAborted(signal);
+
+  if (result.status !== 0) {
+    throw new Error(
+      `Installed ripgrep binary failed validation with exit ${String(result.status)}.`,
+    );
+  }
+
+  const output =
+    `${result.stdout?.toString() ?? ''}\n${result.stderr?.toString() ?? ''}`.toLowerCase();
+  if (!output.includes('ripgrep')) {
+    throw new Error('Installed binary did not identify itself as ripgrep.');
+  }
+}
+
+function ensureArchiveSupport(extension: ArchiveExtension): void {
+  if (extension === 'zip') {
+    const zipError = getZipExtractionSupportError();
+    if (zipError) {
+      throw new Error(zipError);
+    }
+    return;
+  }
+
+  if (!hasExecutable('tar')) {
+    throw new Error(
+      'ripgrep auto-install requires tar to extract .tar.gz archives.',
+    );
+  }
+}
+
+export async function installLatestStableRipgrep(
+  signal?: AbortSignal,
+): Promise<string> {
+  throwIfAborted(signal);
+  const existing = getInstalledRipgrepPath();
+  if (existing) {
+    return existing;
+  }
+
+  const release = await fetchLatestRelease(signal);
+  const { asset } = selectReleaseAsset(release);
+  const extension = (
+    asset.name?.endsWith('.zip') ? 'zip' : 'tar.gz'
+  ) as ArchiveExtension;
+  ensureArchiveSupport(extension);
+  const cacheDir = getRipgrepCacheDir();
+  const binaryName = getRipgrepBinaryName();
+  const finalPath = join(cacheDir, binaryName);
+  const tmpRoot = join(
+    cacheDir,
+    `.install-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+  );
+  const archivePath = join(tmpRoot, asset.name ?? 'ripgrep-archive');
+  const extractDir = join(tmpRoot, 'extract');
+  const stagedBinaryPath = join(tmpRoot, binaryName);
+
+  mkdirSync(cacheDir, { recursive: true });
+  mkdirSync(extractDir, { recursive: true });
+
+  try {
+    await downloadArchive(
+      asset.browser_download_url as string,
+      archivePath,
+      signal,
+    );
+    await extractArchive(archivePath, extractDir, extension, signal);
+    throwIfAborted(signal);
+
+    const extractedBinaryPath = findBinaryRecursive(extractDir, binaryName);
+    if (!extractedBinaryPath) {
+      throw new Error('ripgrep binary was not found after extraction.');
+    }
+
+    renameSync(extractedBinaryPath, stagedBinaryPath);
+    ensureExecutable(stagedBinaryPath);
+    validateInstalledBinary(stagedBinaryPath, signal);
+    throwIfAborted(signal);
+
+    if (!existsSync(finalPath)) {
+      renameSync(stagedBinaryPath, finalPath);
+    }
+
+    if (!existsSync(finalPath)) {
+      throw new Error('ripgrep binary was not installed successfully.');
+    }
+
+    return finalPath;
+  } finally {
+    rmSync(tmpRoot, { recursive: true, force: true });
+    mkdirSync(dirname(finalPath), { recursive: true });
+  }
+}

+ 199 - 0
src/tools/grep/fallback.test.ts

@@ -0,0 +1,199 @@
+/// <reference types="bun-types" />
+import { describe, expect, test } from 'bun:test';
+import { writeFileSync } from 'node:fs';
+import path from 'node:path';
+import { buildGrepCommand, executeGrepFallback } from './fallback';
+import { normalizeGrepInput } from './normalize';
+import { createRepoContext, createTempTracker } from './test-helpers';
+
+describe('tools/grep/fallback', () => {
+  const temps = createTempTracker();
+
+  function createNormalized(
+    input: any,
+    options: { directory?: string; worktree?: string } = {},
+  ) {
+    const repoDir = options.worktree ?? temps.createRepo();
+    const directory = options.directory ?? repoDir;
+    const worktree = options.worktree ?? repoDir;
+
+    return {
+      repoDir,
+      normalized: normalizeGrepInput(
+        input,
+        createRepoContext(directory, worktree) as any,
+      ),
+    };
+  }
+
+  test.each([
+    {
+      name: 'builds lightweight GNU grep count commands without -o byte parsing',
+      input: {
+        pattern: 'createTool',
+        path: 'src',
+        output_mode: 'count',
+        invert_match: true,
+        sort_by: 'mtime',
+      },
+      assertResult(built: ReturnType<typeof buildGrepCommand>) {
+        expect(built.command).toEqual(
+          expect.arrayContaining(['grep', '-H', '-c', '-v']),
+        );
+        expect(built.command).not.toContain('-o');
+        expect(built.command).not.toContain('-b');
+        expect(built.warnings.join('\n')).toContain(
+          'GNU grep fallback count mode reports matching lines per file',
+        );
+        expect(built.warnings.join('\n')).toContain(
+          'GNU grep fallback ignores sort_by=mtime',
+        );
+      },
+    },
+    {
+      name: 'builds GNU grep fallback warnings for path-aware globs',
+      input: {
+        pattern: 'createTool',
+        path: 'src',
+        globs: ['src/**/*.ts'],
+        exclude_globs: ['dist/**'],
+      },
+      assertResult(built: ReturnType<typeof buildGrepCommand>) {
+        expect(built.warnings.join('\n')).toContain(
+          'GNU grep fallback ignores path-aware glob patterns',
+        );
+        expect(built.command).not.toContain('src/**/*.ts');
+        expect(built.command).not.toContain('dist/**');
+      },
+    },
+  ])('$name', ({ input, assertResult }) => {
+    const { normalized } = createNormalized(input);
+    assertResult(buildGrepCommand(normalized, 'grep'));
+  });
+
+  test('executeGrepFallback respects hidden=false by excluding dotfiles', async () => {
+    const repoDir = temps.createRepo();
+    writeFileSync(path.join(repoDir, '.hidden.txt'), 'createTool\n');
+
+    const input = normalizeGrepInput(
+      {
+        pattern: 'createTool',
+        path: repoDir,
+        output_mode: 'files_with_matches',
+        hidden: false,
+        fixed_strings: true,
+      },
+      createRepoContext(repoDir) as any,
+    );
+
+    const result = await executeGrepFallback(
+      input,
+      new AbortController().signal,
+      {
+        path: 'grep',
+        backend: 'grep',
+        source: 'system-gnu-grep',
+      },
+    );
+
+    expect(
+      result.files.some((file) => file.absolutePath.endsWith('.hidden.txt')),
+    ).toBe(false);
+  });
+
+  test('executeGrepFallback parses content mode paths with colons correctly', async () => {
+    const repoDir = temps.createRepo();
+    const colonFile = path.join(repoDir, 'src', 'a:1:b.txt');
+    writeFileSync(colonFile, 'needle\n');
+
+    const input = normalizeGrepInput(
+      {
+        pattern: 'needle',
+        path: path.join(repoDir, 'src'),
+        output_mode: 'content',
+        fixed_strings: true,
+      },
+      createRepoContext(repoDir) as any,
+    );
+
+    const result = await executeGrepFallback(
+      input,
+      new AbortController().signal,
+      {
+        path: 'grep',
+        backend: 'grep',
+        source: 'system-gnu-grep',
+      },
+    );
+
+    const matched = result.files.find(
+      (file) => file.absolutePath === colonFile,
+    );
+    expect(matched).toBeDefined();
+    expect(matched?.matches[0]?.lineText).toBe('needle');
+  });
+
+  test('executeGrepFallback parses files mode paths with embedded newlines correctly', async () => {
+    const repoDir = temps.createRepo();
+    const newlineFile = path.join(repoDir, 'src', 'odd\nname.txt');
+    writeFileSync(newlineFile, 'needle\n');
+
+    const input = normalizeGrepInput(
+      {
+        pattern: 'needle',
+        path: path.join(repoDir, 'src'),
+        output_mode: 'files_with_matches',
+        fixed_strings: true,
+      },
+      createRepoContext(repoDir) as any,
+    );
+
+    const result = await executeGrepFallback(
+      input,
+      new AbortController().signal,
+      {
+        path: 'grep',
+        backend: 'grep',
+        source: 'system-gnu-grep',
+      },
+    );
+
+    expect(result.files.some((file) => file.absolutePath === newlineFile)).toBe(
+      true,
+    );
+    expect(
+      result.files.some((file) => file.absolutePath.endsWith('/odd')),
+    ).toBe(false);
+  });
+
+  test('executeGrepFallback count mode with invert_match counts selected lines', async () => {
+    const repoDir = temps.createRepo();
+    const sample = path.join(repoDir, 'src', 'invert.txt');
+    writeFileSync(sample, 'foo\nbar\nbaz\n');
+
+    const input = normalizeGrepInput(
+      {
+        pattern: 'foo',
+        path: sample,
+        output_mode: 'count',
+        fixed_strings: true,
+        invert_match: true,
+      },
+      createRepoContext(repoDir) as any,
+    );
+
+    const result = await executeGrepFallback(
+      input,
+      new AbortController().signal,
+      {
+        path: 'grep',
+        backend: 'grep',
+        source: 'system-gnu-grep',
+      },
+    );
+
+    const matched = result.files.find((file) => file.absolutePath === sample);
+    expect(matched?.matchCount).toBe(2);
+    expect(result.totalMatches).toBe(2);
+  });
+});

+ 767 - 0
src/tools/grep/fallback.ts

@@ -0,0 +1,767 @@
+import { Readable } from 'node:stream';
+import { GREP_BINARY } from './constants';
+import {
+  consumeNullCountPairsBytes,
+  consumeNullItemsBytes,
+  readTextStream,
+} from './json-stream';
+import {
+  getDisplayPath,
+  normalizeDisplayText,
+  resolveAbsolutePath,
+  stripSingleLineEnding,
+} from './path-utils';
+import type { ResolvedGrepCli } from './resolver';
+import {
+  applySuccessfulStderr,
+  countOccurrences,
+  countVisibleMatches,
+  createEmptyResult,
+  getMatchKind,
+  hasVisibleResults,
+  trimFilesToLineLimit,
+} from './result-utils';
+import { appendContextArgs } from './rg-args';
+import {
+  attachTerminationHandlers,
+  createFriendlySpawnError,
+  type GrepProcess,
+  getAbortKind,
+  killProcess,
+  spawnRipgrep,
+  toErrorMessage,
+  waitForExitAndStderr,
+} from './runtime';
+import type {
+  GrepContextLine,
+  GrepFileMatch,
+  GrepSearchResult,
+  NormalizedGrepInput,
+} from './types';
+
+interface BuiltGrepCommand {
+  command: string[];
+  warnings: string[];
+}
+
+interface ParsedContentRecord {
+  filePath: string;
+  lineNumber: number;
+  text: string;
+  isMatch: boolean;
+}
+
+function isSimpleBasenameGlob(glob: string): boolean {
+  return !glob.includes('/') && !glob.includes('\\') && !glob.includes('**');
+}
+
+const GNU_GREP_CACHE = new Map<string, Promise<string | undefined>>();
+
+function toWebReadableStream(
+  stream: NodeJS.ReadableStream | ReadableStream<Uint8Array> | undefined,
+): ReadableStream<Uint8Array> | undefined {
+  if (!stream) {
+    return undefined;
+  }
+
+  if ('getReader' in stream && typeof stream.getReader === 'function') {
+    return stream as ReadableStream<Uint8Array>;
+  }
+
+  return Readable.toWeb(
+    stream as unknown as Readable,
+  ) as unknown as ReadableStream<Uint8Array>;
+}
+
+function appendGlobArgs(args: string[], input: NormalizedGrepInput): void {
+  const includeGlobs = [
+    ...(input.include ? [input.include] : []),
+    ...input.globs.filter((glob) => !glob.startsWith('!')),
+  ].filter(isSimpleBasenameGlob);
+  const excludeGlobs = [
+    ...input.globs
+      .filter((glob) => glob.startsWith('!'))
+      .map((glob) => glob.slice(1)),
+    ...input.excludeGlobs.map((glob) =>
+      glob.startsWith('!') ? glob.slice(1) : glob,
+    ),
+  ].filter(isSimpleBasenameGlob);
+
+  for (const glob of includeGlobs) {
+    args.push('--include', glob);
+  }
+
+  for (const glob of excludeGlobs) {
+    args.push('--exclude', glob);
+  }
+}
+
+function shouldUseIgnoreCase(input: NormalizedGrepInput): boolean {
+  if (input.smartCase) {
+    return !/[A-Z]/.test(input.pattern);
+  }
+
+  return !input.caseSensitive;
+}
+
+function buildUnsupportedWarnings(input: NormalizedGrepInput): string[] {
+  const warnings: string[] = [];
+
+  if (input.multiline || input.multilineDotall) {
+    warnings.push(
+      'GNU grep fallback does not support multiline matching; results may differ from ripgrep.',
+    );
+  }
+
+  if (input.pcre2) {
+    warnings.push(
+      'GNU grep fallback does not support ripgrep PCRE2 mode; using grep regex support instead.',
+    );
+  }
+
+  if (
+    input.fileType ||
+    input.fileTypes.length > 0 ||
+    input.excludeFileTypes.length > 0
+  ) {
+    warnings.push(
+      'GNU grep fallback ignores ripgrep file type filters; use globs for exact control.',
+    );
+  }
+
+  if (input.maxFilesize) {
+    warnings.push(
+      'GNU grep fallback ignores max_filesize; large files may still be searched.',
+    );
+  }
+
+  const pathAwareGlobs = [
+    ...(input.include ? [input.include] : []),
+    ...input.globs,
+    ...input.excludeGlobs,
+  ].filter(
+    (glob) =>
+      !isSimpleBasenameGlob(glob.startsWith('!') ? glob.slice(1) : glob),
+  );
+
+  if (pathAwareGlobs.length > 0) {
+    warnings.push(
+      'GNU grep fallback ignores path-aware glob patterns; only simple basename globs are supported.',
+    );
+  }
+
+  if (input.sortBy === 'mtime') {
+    warnings.push(
+      'GNU grep fallback ignores sort_by=mtime; returning unsorted direct results instead.',
+    );
+  }
+
+  if (input.outputMode === 'count') {
+    warnings.push(
+      'GNU grep fallback count mode reports matching lines per file, not ripgrep occurrence counts.',
+    );
+  }
+
+  return warnings;
+}
+
+export function buildGrepCommand(
+  input: NormalizedGrepInput,
+  binaryPath = GREP_BINARY,
+): BuiltGrepCommand {
+  const args: string[] = [input.followSymlinks ? '-R' : '-r', '--color=never'];
+  const warnings = buildUnsupportedWarnings(input);
+
+  if (input.outputMode === 'content') {
+    args.push('-Z', '-H', '-n');
+  } else if (input.outputMode === 'files_with_matches') {
+    args.push('-Z', '-l');
+  } else {
+    args.push('-Z', '-H', '-c');
+  }
+
+  if (!input.hidden) {
+    args.push('--exclude=.*', '--exclude-dir=.*');
+  }
+
+  if (shouldUseIgnoreCase(input)) {
+    args.push('-i');
+  }
+
+  if (input.wordRegexp) {
+    args.push('-w');
+  }
+
+  if (input.fixedStrings) {
+    args.push('-F');
+  }
+
+  if (input.invertMatch) {
+    args.push('-v');
+  }
+
+  if (input.maxCountPerFile) {
+    args.push('-m', String(input.maxCountPerFile));
+  }
+
+  appendContextArgs(args, input);
+  appendGlobArgs(args, input);
+  args.push(
+    '-e',
+    input.pattern,
+    ...(input.searchTargets ?? [input.searchPath]),
+  );
+
+  return {
+    command: [binaryPath, ...args],
+    warnings,
+  };
+}
+
+function ensureFileMatch(
+  files: Map<string, GrepFileMatch>,
+  rawPath: string,
+  input: Pick<NormalizedGrepInput, 'cwd' | 'worktree'>,
+): GrepFileMatch {
+  const absolutePath = resolveAbsolutePath(rawPath, input.cwd);
+  const existing = files.get(absolutePath);
+  if (existing) {
+    return existing;
+  }
+
+  const created: GrepFileMatch = {
+    file: getDisplayPath(absolutePath, input.worktree),
+    absolutePath,
+    replayPath: absolutePath,
+    pathKey: absolutePath,
+    matchCount: 0,
+    matches: [],
+  };
+  files.set(absolutePath, created);
+  return created;
+}
+
+function parseContentLine(
+  filePath: string,
+  line: string,
+  withContext: boolean,
+): ParsedContentRecord | null {
+  const match = withContext
+    ? line.match(/^(\d+)([:-])(.*)$/)
+    : line.match(/^(\d+):(.*)$/);
+
+  if (!match) {
+    return null;
+  }
+
+  if (withContext) {
+    const [, lineNumberText, separator, text] = match;
+    return {
+      filePath,
+      lineNumber: Number.parseInt(lineNumberText, 10),
+      text: normalizeDisplayText(stripSingleLineEnding(text)),
+      isMatch: separator === ':',
+    };
+  }
+
+  const [, lineNumberText, text] = match;
+  return {
+    filePath,
+    lineNumber: Number.parseInt(lineNumberText, 10),
+    text: normalizeDisplayText(stripSingleLineEnding(text)),
+    isMatch: true,
+  };
+}
+
+function toContextLine(record: ParsedContentRecord): GrepContextLine {
+  return {
+    lineNumber: record.lineNumber,
+    text: record.text,
+  };
+}
+
+function appendContentGroup(
+  files: Map<string, GrepFileMatch>,
+  records: ParsedContentRecord[],
+  input: Pick<NormalizedGrepInput, 'cwd' | 'worktree'>,
+): void {
+  for (let index = 0; index < records.length; index += 1) {
+    const record = records[index] as ParsedContentRecord;
+    if (!record.isMatch) {
+      continue;
+    }
+
+    const before: GrepContextLine[] = [];
+    const after: GrepContextLine[] = [];
+
+    for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
+      const entry = records[cursor] as ParsedContentRecord;
+      if (entry.isMatch || entry.filePath !== record.filePath) {
+        break;
+      }
+      before.unshift(toContextLine(entry));
+    }
+
+    for (let cursor = index + 1; cursor < records.length; cursor += 1) {
+      const entry = records[cursor] as ParsedContentRecord;
+      if (entry.isMatch || entry.filePath !== record.filePath) {
+        break;
+      }
+      after.push(toContextLine(entry));
+    }
+
+    const file = ensureFileMatch(files, record.filePath, input);
+    file.matchCount += 1;
+    file.matches.push({
+      lineNumber: record.lineNumber,
+      lineText: record.text,
+      submatches: [],
+      before,
+      after,
+    });
+  }
+}
+
+async function consumeNullPrefixedLinesStream(
+  stream: NodeJS.ReadableStream | ReadableStream<Uint8Array> | undefined,
+  onRecord: (record: string) => boolean | undefined,
+): Promise<void> {
+  const readable = toWebReadableStream(stream);
+  if (!readable) {
+    return;
+  }
+
+  const reader = readable.getReader();
+  const decoder = new TextDecoder();
+  let buffer = '';
+
+  while (true) {
+    const { done, value } = await reader.read();
+    buffer += value
+      ? decoder.decode(value, { stream: true })
+      : decoder.decode();
+
+    while (true) {
+      if (buffer.startsWith('--\n')) {
+        buffer = buffer.slice(3);
+        if (onRecord('--') === false) {
+          await reader.cancel();
+          return;
+        }
+        continue;
+      }
+
+      const nullIndex = buffer.indexOf('\0');
+      if (nullIndex < 0) {
+        break;
+      }
+
+      const lineEnd = buffer.indexOf('\n', nullIndex + 1);
+      if (lineEnd < 0) {
+        break;
+      }
+
+      const record = stripSingleLineEnding(buffer.slice(0, lineEnd));
+      buffer = buffer.slice(lineEnd + 1);
+      if (onRecord(record) === false) {
+        await reader.cancel();
+        return;
+      }
+    }
+
+    if (done) {
+      break;
+    }
+  }
+
+  if (buffer.length > 0) {
+    onRecord(stripSingleLineEnding(buffer));
+  }
+}
+
+async function consumeContentOutput(
+  stdout: NodeJS.ReadableStream | ReadableStream<Uint8Array> | undefined,
+  proc: GrepProcess,
+  input: Pick<
+    NormalizedGrepInput,
+    | 'afterContext'
+    | 'beforeContext'
+    | 'cwd'
+    | 'maxResults'
+    | 'outputMode'
+    | 'worktree'
+  >,
+): Promise<{
+  files: GrepFileMatch[];
+  skippedLines: number;
+  limitReached: boolean;
+}> {
+  const files = new Map<string, GrepFileMatch>();
+  const withContext = input.beforeContext > 0 || input.afterContext > 0;
+  let group: ParsedContentRecord[] = [];
+  let skippedLines = 0;
+  let visibleMatches = 0;
+  let limitReached = false;
+
+  const stopForLimit = async () => {
+    limitReached = true;
+    killProcess(proc);
+  };
+
+  await consumeNullPrefixedLinesStream(stdout, (record) => {
+    if (withContext && record === '--') {
+      appendContentGroup(files, group, input);
+      group = [];
+      visibleMatches = countVisibleMatches([...files.values()]);
+      if (visibleMatches >= input.maxResults) {
+        void stopForLimit();
+        return false;
+      }
+      return true;
+    }
+
+    const nulIndex = record.indexOf('\0');
+    if (nulIndex < 0) {
+      skippedLines += 1;
+      return true;
+    }
+
+    const filePath = record.slice(0, nulIndex);
+    const line = record.slice(nulIndex + 1);
+    const parsed = parseContentLine(filePath, line, withContext);
+    if (!parsed) {
+      skippedLines += 1;
+      return true;
+    }
+
+    if (withContext) {
+      group.push(parsed);
+      return true;
+    }
+
+    const file = ensureFileMatch(files, parsed.filePath, input);
+    file.matchCount += 1;
+    file.matches.push({
+      lineNumber: parsed.lineNumber,
+      lineText: parsed.text,
+      submatches: [],
+      before: [],
+      after: [],
+    });
+    visibleMatches += 1;
+    if (visibleMatches >= input.maxResults) {
+      void stopForLimit();
+      return false;
+    }
+    return true;
+  });
+
+  if (group.length > 0) {
+    appendContentGroup(files, group, input);
+    visibleMatches = countVisibleMatches([...files.values()]);
+    limitReached = limitReached || visibleMatches >= input.maxResults;
+  }
+
+  return {
+    files: [...files.values()],
+    skippedLines,
+    limitReached,
+  };
+}
+
+async function consumeCountOutput(
+  stdout: NodeJS.ReadableStream | ReadableStream<Uint8Array> | undefined,
+  proc: GrepProcess,
+  input: Pick<NormalizedGrepInput, 'cwd' | 'maxResults' | 'worktree'>,
+): Promise<{
+  files: GrepFileMatch[];
+  skippedLines: number;
+  limitReached: boolean;
+}> {
+  const files = new Map<string, GrepFileMatch>();
+  let skippedLines = 0;
+  let limitReached = false;
+  const decoder = new TextDecoder();
+
+  await consumeNullCountPairsBytes(stdout, (filePath, countText) => {
+    if (!/^\d+$/.test(countText)) {
+      skippedLines += 1;
+      return true;
+    }
+
+    const file = ensureFileMatch(files, decoder.decode(filePath), input);
+    file.matchCount = Number.parseInt(countText, 10);
+    if (files.size >= input.maxResults) {
+      limitReached = true;
+      killProcess(proc);
+      return false;
+    }
+
+    return true;
+  });
+
+  return {
+    files: [...files.values()],
+    skippedLines,
+    limitReached,
+  };
+}
+
+async function consumeFilesOutput(
+  stdout: NodeJS.ReadableStream | ReadableStream<Uint8Array> | undefined,
+  proc: GrepProcess,
+  input: Pick<NormalizedGrepInput, 'cwd' | 'maxResults' | 'worktree'>,
+): Promise<{
+  files: GrepFileMatch[];
+  skippedLines: number;
+  limitReached: boolean;
+}> {
+  const files = new Map<string, GrepFileMatch>();
+  const decoder = new TextDecoder();
+  let limitReached = false;
+
+  await consumeNullItemsBytes(stdout, (filePath) => {
+    if (filePath.length === 0) {
+      return true;
+    }
+
+    const file = ensureFileMatch(files, decoder.decode(filePath), input);
+    file.matchCount = 1;
+    if (files.size >= input.maxResults) {
+      limitReached = true;
+      killProcess(proc);
+      return false;
+    }
+
+    return true;
+  });
+
+  return {
+    files: [...files.values()],
+    skippedLines: 0,
+    limitReached,
+  };
+}
+
+function sortFiles(
+  files: GrepFileMatch[],
+  input: Pick<NormalizedGrepInput, 'sortBy' | 'sortOrder'>,
+): GrepFileMatch[] {
+  if (input.sortBy !== 'path') {
+    return files;
+  }
+
+  return [...files].sort((left, right) => {
+    const direction = input.sortOrder === 'desc' ? -1 : 1;
+    return left.file.localeCompare(right.file) * direction;
+  });
+}
+
+function finalizeFiles(
+  files: GrepFileMatch[],
+  input: Pick<NormalizedGrepInput, 'maxResults' | 'outputMode'>,
+): {
+  files: GrepFileMatch[];
+  totalMatches: number;
+  totalFiles: number;
+  limitReached: boolean;
+} {
+  if (input.outputMode === 'content') {
+    const trimmed = trimFilesToLineLimit(files, input.maxResults);
+    const limitReached = files.some((file, index) => {
+      const visible = trimmed[index];
+      return visible ? visible.matches.length < file.matches.length : true;
+    });
+
+    return {
+      files: trimmed,
+      totalMatches: countVisibleMatches(trimmed),
+      totalFiles: trimmed.length,
+      limitReached,
+    };
+  }
+
+  const trimmed = files.slice(0, input.maxResults);
+  return {
+    files: trimmed,
+    totalMatches:
+      input.outputMode === 'count' ? countOccurrences(trimmed) : trimmed.length,
+    totalFiles: trimmed.length,
+    limitReached: trimmed.length < files.length,
+  };
+}
+
+async function ensureGnuGrep(binaryPath: string): Promise<string | undefined> {
+  const cached = GNU_GREP_CACHE.get(binaryPath);
+  if (cached) {
+    return cached;
+  }
+
+  const check = (async () => {
+    let proc: GrepProcess;
+    try {
+      proc = spawnRipgrep([binaryPath, '--version'], process.cwd());
+    } catch (error) {
+      return toErrorMessage(error);
+    }
+
+    const stdoutPromise = readTextStream(proc.proc.stdout ?? undefined);
+    const stderrPromise = readTextStream(proc.proc.stderr ?? undefined);
+    const { exitCode, stderr } = await waitForExitAndStderr(
+      proc,
+      stderrPromise,
+    );
+    const stdout = (await stdoutPromise).trim();
+
+    if (exitCode !== 0) {
+      return stderr || `grep --version exited with code ${String(exitCode)}`;
+    }
+
+    const firstLine = stdout.split(/\r?\n/, 1)[0] ?? '';
+    if (!firstLine.includes('GNU grep')) {
+      return 'System grep fallback requires GNU grep; the detected grep is not GNU grep.';
+    }
+
+    return undefined;
+  })();
+
+  GNU_GREP_CACHE.set(binaryPath, check);
+  return check;
+}
+
+export async function executeGrepFallback(
+  input: NormalizedGrepInput,
+  signal: AbortSignal,
+  cli: ResolvedGrepCli,
+): Promise<GrepSearchResult> {
+  const { command, warnings: commandWarnings } = buildGrepCommand(
+    input,
+    cli.path,
+  );
+  const baseResult = createEmptyResult(input, command);
+
+  if (signal.aborted) {
+    return {
+      ...baseResult,
+      backend: 'grep',
+      truncated: true,
+      timedOut: getAbortKind(signal) === 'timeout',
+      cancelled: getAbortKind(signal) !== 'timeout',
+      warnings: [...commandWarnings],
+    };
+  }
+
+  const grepError = await ensureGnuGrep(cli.path);
+  if (grepError) {
+    return {
+      ...baseResult,
+      backend: 'grep',
+      error: grepError,
+      warnings: [...commandWarnings],
+    };
+  }
+
+  let proc: GrepProcess;
+  try {
+    proc = spawnRipgrep(command, input.cwd);
+  } catch (error) {
+    const friendlyMessage = createFriendlySpawnError(error, cli);
+    return {
+      ...baseResult,
+      backend: 'grep',
+      error: friendlyMessage || toErrorMessage(error),
+      warnings: [...commandWarnings],
+    };
+  }
+
+  const termination = attachTerminationHandlers(proc, input.timeoutMs, signal);
+
+  try {
+    const stdoutStream = proc.proc.stdout ?? undefined;
+    const stderrStream = proc.proc.stderr ?? undefined;
+    const stderrPromise = readTextStream(stderrStream);
+    const stdoutPromise =
+      input.outputMode === 'content'
+        ? consumeContentOutput(stdoutStream, proc, input)
+        : input.outputMode === 'count'
+          ? consumeCountOutput(stdoutStream, proc, input)
+          : consumeFilesOutput(stdoutStream, proc, input);
+
+    const [stdoutResult, exitResult] = await Promise.allSettled([
+      stdoutPromise,
+      waitForExitAndStderr(proc, stderrPromise),
+    ]);
+    const exitCode =
+      exitResult.status === 'fulfilled' ? exitResult.value.exitCode : 1;
+    const stderr =
+      exitResult.status === 'fulfilled' ? exitResult.value.stderr.trim() : '';
+    const parsed =
+      stdoutResult.status === 'fulfilled'
+        ? stdoutResult.value
+        : {
+            files: [] as GrepFileMatch[],
+            skippedLines: 0,
+            limitReached: false,
+          };
+    const sortedFiles = sortFiles(parsed.files, input);
+    const finalized = finalizeFiles(sortedFiles, input);
+    const warnings = [...commandWarnings];
+
+    if (parsed.skippedLines > 0) {
+      warnings.push(
+        `GNU grep fallback skipped ${String(parsed.skippedLines)} unparsable output line(s); results may be incomplete.`,
+      );
+    }
+
+    const result: GrepSearchResult = {
+      ...baseResult,
+      backend: 'grep',
+      files: finalized.files,
+      totalMatches: finalized.totalMatches,
+      totalFiles: finalized.totalFiles,
+      matchKind: getMatchKind(input.outputMode),
+      truncated:
+        parsed.limitReached ||
+        finalized.limitReached ||
+        termination.state.timedOut ||
+        termination.state.cancelled ||
+        parsed.skippedLines > 0,
+      limitReached: parsed.limitReached || finalized.limitReached,
+      timedOut: termination.state.timedOut,
+      cancelled: termination.state.cancelled,
+      exitCode,
+      stderr,
+      warnings,
+    };
+
+    applySuccessfulStderr(result, stderr, exitCode);
+
+    if (
+      parsed.skippedLines > 0 &&
+      !hasVisibleResults(result) &&
+      exitCode === 0 &&
+      !result.timedOut &&
+      !result.cancelled
+    ) {
+      result.error = 'GNU grep fallback produced unparsable output.';
+      return result;
+    }
+
+    if (termination.state.timedOut || termination.state.cancelled) {
+      return result;
+    }
+
+    if (result.limitReached) {
+      return result;
+    }
+
+    if (exitCode === 0 || (exitCode === 1 && !hasVisibleResults(result))) {
+      return result;
+    }
+
+    result.error = result.stderr || `grep exited with code ${String(exitCode)}`;
+    return result;
+  } finally {
+    termination.cleanup();
+  }
+}

+ 428 - 0
src/tools/grep/format.test.ts

@@ -0,0 +1,428 @@
+/// <reference types="bun-types" />
+import { describe, expect, test } from 'bun:test';
+import path from 'node:path';
+import { DEFAULT_GREP_LIMIT, DEFAULT_GREP_TIMEOUT_MS } from './constants';
+import { formatGrepResult } from './format';
+import { normalizeGrepInput } from './normalize';
+import {
+  buildResult,
+  createRepoContext,
+  createTempTracker,
+} from './test-helpers';
+import type { GrepSearchResult } from './types';
+
+describe('tools/grep/format', () => {
+  const temps = createTempTracker();
+
+  function createNormalized(
+    input: any,
+    options: { directory?: string; worktree?: string } = {},
+  ) {
+    const repoDir = options.worktree ?? temps.createRepo();
+    const directory = options.directory ?? repoDir;
+    const worktree = options.worktree ?? repoDir;
+
+    return {
+      repoDir,
+      normalized: normalizeGrepInput(
+        input,
+        createRepoContext(directory, worktree) as any,
+      ),
+    };
+  }
+
+  test('formats partial output notes for limit and retry cases', () => {
+    const { repoDir, normalized } = createNormalized({
+      pattern: 'createTool',
+      path: 'src',
+    });
+
+    const output = formatGrepResult(normalized, {
+      ...buildResult(repoDir),
+      truncated: true,
+      limitReached: true,
+      retryCount: 1,
+      warnings: ['permission warning'],
+    });
+
+    expect(output).toContain(
+      `Stopped after collecting ${DEFAULT_GREP_LIMIT} matches`,
+    );
+    expect(output).toContain('Retried 1 transient failure.');
+    expect(output).toContain('permission warning');
+  });
+
+  test('formats files_with_matches mode cleanly', () => {
+    const { repoDir, normalized } = createNormalized({
+      pattern: 'createTool',
+      path: 'src',
+      output_mode: 'files_with_matches',
+    });
+
+    const output = formatGrepResult(normalized, {
+      ...buildResult(repoDir),
+      outputMode: 'files_with_matches',
+      matchKind: 'file',
+      totalMatches: 1,
+      files: [
+        {
+          file: 'src/example.ts',
+          absolutePath: path.join(repoDir, 'src', 'example.ts'),
+          matchCount: 1,
+          matches: [],
+        },
+      ],
+    });
+
+    expect(output).toContain('Found 1 matching file.');
+    expect(output).toContain('src/example.ts');
+    expect(output).not.toContain('Line');
+  });
+
+  test.each([
+    {
+      name: 'escapes control characters in displayed file paths',
+      createResult: (repoDir: string): GrepSearchResult => ({
+        ...buildResult(repoDir),
+        outputMode: 'files_with_matches',
+        matchKind: 'file',
+        totalMatches: 1,
+        files: [
+          {
+            file: 'src/odd\nname\r\t.ts',
+            absolutePath: path.join(repoDir, 'src', 'odd\nname\r\t.ts'),
+            matchCount: 1,
+            matches: [],
+          },
+        ],
+      }),
+      assertOutput: (output: string) => {
+        expect(output).toContain('src/odd\\nname\\r\\t.ts');
+      },
+    },
+    {
+      name: 'preserves normal Windows backslashes in displayed paths',
+      createResult: (repoDir: string): GrepSearchResult => ({
+        ...buildResult(repoDir),
+        outputMode: 'files_with_matches',
+        matchKind: 'file',
+        totalMatches: 1,
+        files: [
+          {
+            file: 'C:\\repo\\src\\file.ts',
+            absolutePath: 'C:\\repo\\src\\file.ts',
+            matchCount: 1,
+            matches: [],
+          },
+        ],
+      }),
+      assertOutput: (output: string) => {
+        expect(output).toContain('C:\\repo\\src\\file.ts');
+        expect(output).not.toContain('C:\\\\repo');
+      },
+    },
+  ])('$name', ({ createResult, assertOutput }) => {
+    const { repoDir, normalized } = createNormalized({
+      pattern: 'createTool',
+      path: 'src',
+      output_mode: 'files_with_matches',
+    });
+
+    assertOutput(formatGrepResult(normalized, createResult(repoDir)));
+  });
+
+  test.each([
+    {
+      name: 'escapes control characters in pattern, include, warnings and stderr',
+      input: {
+        pattern: 'create\nTool\t',
+        path: 'src',
+        include: 'weird\n*.ts',
+      },
+      result: (repoDir: string): GrepSearchResult => ({
+        ...buildResult(repoDir),
+        warnings: ['warn\nline'],
+        stderr: 'stderr\tline',
+        error: 'partial\rerror',
+      }),
+      expected: [
+        'Pattern: create\\nTool\\t',
+        'Include: weird\\n*.ts',
+        'Partial error: partial\\rerror',
+        'warn\\nline',
+        'stderr\\tline',
+      ],
+    },
+    {
+      name: 'escapes ansi and control characters in headers, paths and notes',
+      input: {
+        pattern: 'create\u001b[31mTool',
+        path: 'src',
+        include: 'weird\u0007*.ts',
+      },
+      result: (repoDir: string): GrepSearchResult => ({
+        ...buildResult(repoDir),
+        files: [
+          {
+            file: 'src/evil\u001b[31m.ts',
+            absolutePath: path.join(repoDir, 'src', 'evil.ts'),
+            matchCount: 1,
+            matches: [],
+          },
+        ],
+        outputMode: 'files_with_matches',
+        matchKind: 'file',
+        totalMatches: 1,
+        totalFiles: 1,
+        warnings: ['warn\u0007line'],
+        stderr: 'stderr\u001b[31mline',
+        error: 'partial\u001berror',
+      }),
+      expected: [
+        'Pattern: create\\x1b[31mTool',
+        'Include: weird\\x07*.ts',
+        'src/evil\\x1b[31m.ts',
+        'warn\\x07line',
+        'stderr\\x1b[31mline',
+        'Partial error: partial\\x1berror',
+      ],
+    },
+  ])('$name', ({ input, result, expected }) => {
+    const { repoDir, normalized } = createNormalized(input);
+    const output = formatGrepResult(normalized, result(repoDir));
+
+    for (const fragment of expected) {
+      expect(output).toContain(fragment);
+    }
+  });
+
+  test('formats count mode with per-file counts', () => {
+    const { repoDir, normalized } = createNormalized({
+      pattern: 'createTool',
+      path: 'src',
+      output_mode: 'count',
+    });
+
+    const output = formatGrepResult(normalized, {
+      ...buildResult(repoDir),
+      outputMode: 'count',
+      matchKind: 'occurrence',
+      files: [
+        {
+          file: 'src/z-last.ts',
+          absolutePath: path.join(repoDir, 'src', 'z-last.ts'),
+          matchCount: 2,
+          matches: [],
+        },
+        {
+          file: 'src/example.ts',
+          absolutePath: path.join(repoDir, 'src', 'example.ts'),
+          matchCount: 3,
+          matches: [],
+        },
+      ],
+      totalMatches: 5,
+      totalFiles: 2,
+    });
+
+    expect(output).toContain('Found 5 total matches across 2 files.');
+    const zIndex = output.indexOf('2: src/z-last.ts');
+    const exampleIndex = output.indexOf('3: src/example.ts');
+    expect(zIndex).toBeGreaterThan(-1);
+    expect(exampleIndex).toBeGreaterThan(-1);
+    expect(zIndex).toBeLessThan(exampleIndex);
+  });
+
+  test.each([
+    {
+      name: 'count mode keeps repo-relative file paths',
+      createInput: (repoDir: string) => ({
+        input: {
+          pattern: 'createTool',
+          path: 'src',
+          output_mode: 'count',
+        },
+        options: { worktree: repoDir },
+      }),
+      createResult: (repoDir: string): GrepSearchResult => ({
+        ...buildResult(repoDir),
+        outputMode: 'count',
+        matchKind: 'occurrence',
+        files: [
+          {
+            file: 'src/example.ts',
+            absolutePath: path.join(repoDir, 'src', 'example.ts'),
+            matchCount: 1,
+            matches: [],
+          },
+        ],
+        totalMatches: 1,
+      }),
+      assertOutput: (output: string, repoDir: string) => {
+        expect(output).toContain('1: src/example.ts');
+        expect(output).not.toContain(repoDir);
+      },
+    },
+    {
+      name: 'keeps absolute paths when worktree is filesystem root',
+      createInput: (repoDir: string) => ({
+        input: {
+          pattern: 'createTool',
+          path: repoDir,
+          output_mode: 'count',
+        },
+        options: { worktree: path.parse(repoDir).root },
+      }),
+      createResult: (repoDir: string): GrepSearchResult => ({
+        ...buildResult(repoDir),
+        outputMode: 'count',
+        matchKind: 'occurrence',
+        files: [
+          {
+            file: path.join(repoDir, 'src', 'example.ts'),
+            absolutePath: path.join(repoDir, 'src', 'example.ts'),
+            matchCount: 1,
+            matches: [],
+          },
+        ],
+        totalMatches: 1,
+      }),
+      assertOutput: (output: string, repoDir: string) => {
+        expect(output).toContain(path.join(repoDir, 'src', 'example.ts'));
+      },
+    },
+  ])('$name', ({ createInput, createResult, assertOutput }) => {
+    const repoDir = temps.createRepo();
+    const { input, options } = createInput(repoDir);
+    const { normalized } = createNormalized(input, {
+      directory: repoDir,
+      ...options,
+    });
+    const output = formatGrepResult(normalized, createResult(repoDir));
+
+    assertOutput(output, repoDir);
+  });
+
+  test.each([
+    {
+      name: 'formats partial-empty timeout output without claiming no matches',
+      input: { pattern: 'createTool', path: 'src' },
+      result: (repoDir: string): GrepSearchResult => ({
+        ...buildResult(repoDir),
+        files: [],
+        totalMatches: 0,
+        totalFiles: 0,
+        truncated: true,
+        timedOut: true,
+      }),
+      expected: [
+        'No visible results were collected before the search stopped.',
+        `Timed out after ${DEFAULT_GREP_TIMEOUT_MS}ms; showing partial results.`,
+      ],
+      unexpected: ['No matches found.'],
+    },
+    {
+      name: 'formats mtime discovery partials without pretending no matches were found',
+      input: { pattern: 'needle', path: 'src', sort_by: 'mtime' },
+      result: (repoDir: string): GrepSearchResult => ({
+        ...buildResult(repoDir),
+        files: [],
+        totalMatches: 0,
+        totalFiles: 0,
+        truncated: true,
+        timedOut: true,
+        partialPhase: 'discovery',
+        discoveredFiles: 3,
+        strategy: 'mtime-hybrid',
+      }),
+      expected: [
+        'Search stopped during mtime discovery after discovering 3 candidate files before replay produced visible results.',
+      ],
+      unexpected: [
+        'No visible results were collected before the search stopped.',
+      ],
+    },
+    {
+      name: 'formats mtime replay partials as replay instead of discovery',
+      input: { pattern: 'needle', path: 'src', sort_by: 'mtime' },
+      result: (repoDir: string): GrepSearchResult => ({
+        ...buildResult(repoDir),
+        files: [],
+        totalMatches: 0,
+        totalFiles: 0,
+        truncated: true,
+        timedOut: true,
+        partialPhase: 'replay',
+        discoveredFiles: 2,
+        strategy: 'mtime-hybrid',
+      }),
+      expected: [
+        'Search stopped during mtime replay after discovering 2 candidate files before replay produced visible results.',
+      ],
+      unexpected: [],
+    },
+    {
+      name: 'formats non-stopped replay partials without claiming the search stopped',
+      input: { pattern: 'needle', path: 'src', sort_by: 'mtime' },
+      result: (repoDir: string): GrepSearchResult => ({
+        ...buildResult(repoDir),
+        files: [],
+        totalMatches: 0,
+        totalFiles: 0,
+        truncated: true,
+        partialPhase: 'replay',
+        discoveredFiles: 2,
+        strategy: 'mtime-hybrid',
+        warnings: ['Skipped replay batch'],
+      }),
+      expected: [
+        'mtime replay could not produce visible results after discovering 2 candidate files.',
+      ],
+      unexpected: ['Search stopped during mtime replay'],
+    },
+  ])('$name', ({ input, result, expected, unexpected }) => {
+    const { repoDir, normalized } = createNormalized(input);
+    const output = formatGrepResult(normalized, result(repoDir));
+
+    for (const fragment of expected) {
+      expect(output).toContain(fragment);
+    }
+
+    for (const fragment of unexpected) {
+      expect(output).not.toContain(fragment);
+    }
+  });
+
+  test('does not show rg elapsed for mtime-hybrid summaries', () => {
+    const { repoDir, normalized } = createNormalized({
+      pattern: 'needle',
+      path: 'src',
+      sort_by: 'mtime',
+    });
+
+    const output = formatGrepResult(normalized, {
+      ...buildResult(repoDir),
+      strategy: 'mtime-hybrid',
+      summary: { elapsedTotalHuman: '9.9s' },
+    });
+
+    expect(output).not.toContain('rg elapsed:');
+  });
+
+  test('formats partial body when an error happens after visible results', () => {
+    const { repoDir, normalized } = createNormalized({
+      pattern: 'createTool',
+      path: 'src',
+    });
+
+    const output = formatGrepResult(normalized, {
+      ...buildResult(repoDir),
+      truncated: true,
+      error: 'permission denied',
+    });
+
+    expect(output).toContain('src/example.ts');
+    expect(output).toContain('Partial error: permission denied');
+    expect(output).not.toContain('grep search failed.');
+  });
+});

+ 221 - 0
src/tools/grep/format.ts

@@ -0,0 +1,221 @@
+import { MAX_LINE_LENGTH } from './constants';
+import {
+  escapeControlChars,
+  escapeControlCharsPreservingNewlines,
+} from './path-utils';
+import { buildLimitNote, buildPrimarySummary, pluralize } from './summary';
+import type { GrepSearchResult, NormalizedGrepInput } from './types';
+
+function formatLine(
+  lineNumber: number,
+  marker: ':' | '-',
+  text: string,
+): string {
+  const escapedText = escapeControlCharsPreservingNewlines(text);
+  const visibleText =
+    escapedText.length > MAX_LINE_LENGTH
+      ? `${escapedText.slice(0, MAX_LINE_LENGTH)}...`
+      : escapedText;
+  const parts = visibleText.split('\n');
+  if (parts.length <= 1) {
+    return `  ${String(lineNumber).padStart(5)}${marker} ${parts[0] ?? ''}`;
+  }
+
+  const [first, ...rest] = parts;
+  return [
+    `  ${String(lineNumber).padStart(5)}${marker} ${first ?? ''}`,
+    ...rest.map((line) => `         ${line}`),
+  ].join('\n');
+}
+
+function trimBlankEdges(lines: string[]): string[] {
+  while (lines[0] === '') {
+    lines.shift();
+  }
+
+  while (lines[lines.length - 1] === '') {
+    lines.pop();
+  }
+
+  return lines;
+}
+
+function appendSearchHeader(
+  lines: string[],
+  input: Pick<
+    NormalizedGrepInput,
+    'include' | 'requestedPath' | 'resolvedPath' | 'searchPath' | 'pattern'
+  >,
+): void {
+  lines.push(`Pattern: ${escapeControlChars(input.pattern)}`);
+  lines.push(`Path: ${escapeControlChars(input.requestedPath)}`);
+  if (input.searchPath !== input.resolvedPath) {
+    lines.push(`Real path: ${escapeControlChars(input.searchPath)}`);
+  }
+  if (input.include) {
+    lines.push(`Include: ${escapeControlChars(input.include)}`);
+  }
+}
+
+function formatFilesOnly(result: GrepSearchResult): string[] {
+  const lines = [buildPrimarySummary(result), ''];
+
+  for (const file of result.files) {
+    lines.push(escapeControlChars(file.file));
+  }
+
+  return lines;
+}
+
+function formatCountMode(result: GrepSearchResult): string[] {
+  const lines = [buildPrimarySummary(result), ''];
+
+  for (const file of result.files) {
+    lines.push(
+      `  ${String(file.matchCount).padStart(6)}: ${escapeControlChars(file.file)}`,
+    );
+  }
+
+  return lines;
+}
+
+function formatContentMode(result: GrepSearchResult): string[] {
+  const lines = [buildPrimarySummary(result), ''];
+
+  result.files.forEach((file, fileIndex) => {
+    lines.push(escapeControlChars(file.file));
+
+    file.matches.forEach((match, matchIndex) => {
+      for (const before of match.before) {
+        lines.push(formatLine(before.lineNumber, '-', before.text));
+      }
+
+      lines.push(formatLine(match.lineNumber, ':', match.lineText));
+
+      for (const after of match.after) {
+        lines.push(formatLine(after.lineNumber, '-', after.text));
+      }
+
+      if (matchIndex < file.matches.length - 1) {
+        lines.push('');
+      }
+    });
+
+    if (fileIndex < result.files.length - 1) {
+      lines.push('');
+    }
+  });
+
+  return lines;
+}
+
+function buildNoVisibleResultsMessage(
+  input: NormalizedGrepInput,
+  result: GrepSearchResult,
+): string {
+  const discoveredFiles = result.discoveredFiles ?? 0;
+  if (result.partialPhase && discoveredFiles > 0) {
+    const phase =
+      result.partialPhase === 'mtime-sort'
+        ? 'sorting'
+        : result.partialPhase === 'replay'
+          ? 'replay'
+          : 'discovery';
+    if (!result.timedOut && !result.cancelled) {
+      return `mtime ${phase} could not produce visible results after discovering ${discoveredFiles} candidate ${pluralize(discoveredFiles, 'file')}.`;
+    }
+    return `Search stopped during mtime ${phase} after discovering ${discoveredFiles} candidate ${pluralize(discoveredFiles, 'file')} before replay produced visible results.`;
+  }
+
+  if (result.timedOut || result.cancelled) {
+    return input.outputMode === 'files_with_matches'
+      ? 'No visible files were collected before the search stopped.'
+      : 'No visible results were collected before the search stopped.';
+  }
+
+  return input.outputMode === 'files_with_matches'
+    ? 'No files found.'
+    : 'No matches found.';
+}
+
+export function formatGrepResult(
+  input: NormalizedGrepInput,
+  result: GrepSearchResult,
+): string {
+  const lines: string[] = [];
+
+  if (result.error && result.totalMatches === 0 && result.totalFiles === 0) {
+    lines.push('grep search failed.');
+    appendSearchHeader(lines, input);
+    lines.push('');
+    lines.push(`Error: ${escapeControlChars(result.error)}`);
+    if (result.stderr.trim()) {
+      lines.push('');
+      lines.push(escapeControlChars(result.stderr.trim()));
+    }
+    return lines.join('\n');
+  }
+
+  if (result.totalMatches === 0) {
+    lines.push(buildNoVisibleResultsMessage(input, result));
+    appendSearchHeader(lines, input);
+  } else {
+    appendSearchHeader(lines, input);
+    lines.push('');
+
+    const formattedBody =
+      input.outputMode === 'files_with_matches'
+        ? formatFilesOnly(result)
+        : input.outputMode === 'count'
+          ? formatCountMode(result)
+          : formatContentMode(result);
+    lines.push(...formattedBody);
+  }
+
+  const notes: string[] = [];
+
+  if (result.limitReached) {
+    notes.push(buildLimitNote(input, result));
+  }
+
+  if (result.timedOut) {
+    notes.push(
+      `Timed out after ${input.timeoutMs}ms; showing partial results.`,
+    );
+  }
+
+  if (result.cancelled) {
+    notes.push('Search was cancelled; showing partial results.');
+  }
+
+  if (result.retryCount > 0) {
+    notes.push(
+      `Retried ${result.retryCount} transient ${pluralize(result.retryCount, 'failure')}.`,
+    );
+  }
+
+  if (result.error) {
+    notes.push(`Partial error: ${escapeControlChars(result.error)}`);
+  }
+
+  if (result.strategy !== 'mtime-hybrid' && result.summary?.elapsedTotalHuman) {
+    notes.push(`rg elapsed: ${result.summary.elapsedTotalHuman}`);
+  }
+
+  for (const warning of result.warnings) {
+    notes.push(escapeControlChars(warning));
+  }
+
+  const stderr = result.stderr.trim();
+  if (stderr.length > 0) {
+    notes.push(escapeControlChars(stderr));
+  }
+
+  if (notes.length > 0) {
+    lines.push('');
+    lines.push('---');
+    lines.push(...notes);
+  }
+
+  return trimBlankEdges(lines).join('\n');
+}

+ 31 - 0
src/tools/grep/index.ts

@@ -0,0 +1,31 @@
+export { GREP_DESCRIPTION, GREP_TOOL_ID } from './constants';
+export {
+  getInstalledRipgrepPath,
+  getRipgrepBinaryName,
+  getRipgrepCacheDir,
+  installLatestStableRipgrep,
+} from './downloader';
+export { buildGrepCommand, executeGrepFallback } from './fallback';
+export { formatGrepResult } from './format';
+export { normalizeGrepInput } from './normalize';
+export {
+  resetGrepCliResolverForTests,
+  resolveGrepCli,
+  resolveGrepCliWithAutoInstall,
+} from './resolver';
+export { buildRgArgs, buildRgCommand } from './rg-args';
+export { runRipgrep } from './runner';
+export { grepArgsSchema } from './schema';
+export { createGrepTool } from './tool';
+export type {
+  GrepBackend,
+  GrepContextLine,
+  GrepFileMatch,
+  GrepMatch,
+  GrepRunner,
+  GrepSearchResult,
+  GrepSummaryData,
+  GrepToolInput,
+  NormalizedGrepInput,
+  RgJsonEvent,
+} from './types';

+ 368 - 0
src/tools/grep/json-stream.ts

@@ -0,0 +1,368 @@
+import { Readable } from 'node:stream';
+import { MAX_STDERR_CHARS, RG_BINARY } from './constants';
+import { formatNonUtf8TextDisplay, tryDecodeUtf8 } from './path-utils';
+import type { RgJsonEvent, RgPathPayload, RgTextPayload } from './types';
+
+function decodeChunk(decoder: TextDecoder, chunk?: Uint8Array): string {
+  return chunk ? decoder.decode(chunk, { stream: true }) : decoder.decode();
+}
+
+function decodeBase64Text(value: string): string {
+  const bytes = Buffer.from(value, 'base64');
+  return tryDecodeUtf8(bytes) ?? formatNonUtf8TextDisplay(bytes);
+}
+
+export function decodeRgPayload(
+  payload: RgTextPayload | RgPathPayload | undefined,
+): string {
+  if (!payload) {
+    return '';
+  }
+
+  if (typeof payload.text === 'string') {
+    return payload.text;
+  }
+
+  if (typeof payload.bytes === 'string') {
+    return decodeBase64Text(payload.bytes);
+  }
+
+  return '';
+}
+
+type BinaryReadableStream =
+  | NodeJS.ReadableStream
+  | ReadableStream<Uint8Array>
+  | null
+  | undefined;
+
+function toWebReadableStream(
+  stream: BinaryReadableStream,
+): ReadableStream<Uint8Array> | null {
+  if (!stream) {
+    return null;
+  }
+
+  if ('getReader' in stream && typeof stream.getReader === 'function') {
+    return stream as ReadableStream<Uint8Array>;
+  }
+
+  return Readable.toWeb(
+    stream as unknown as Readable,
+  ) as unknown as ReadableStream<Uint8Array>;
+}
+
+async function consumeDelimitedText(
+  stream: BinaryReadableStream,
+  delimiter: string,
+  onItem: (line: string) => boolean | undefined,
+  options?: {
+    flushTrailing?: boolean;
+    normalizeItem?: (item: string) => string;
+  },
+): Promise<void> {
+  const readable = toWebReadableStream(stream);
+  if (!readable) {
+    return;
+  }
+
+  const reader = readable.getReader();
+  const decoder = new TextDecoder();
+  let buffer = '';
+  const flushTrailing = options?.flushTrailing !== false;
+  const normalizeItem = options?.normalizeItem ?? ((item: string) => item);
+
+  while (true) {
+    const { done, value } = await reader.read();
+    buffer += decodeChunk(decoder, value);
+
+    let index = buffer.indexOf(delimiter);
+    while (index >= 0) {
+      const item = normalizeItem(buffer.slice(0, index));
+      buffer = buffer.slice(index + delimiter.length);
+
+      if (onItem(item) === false) {
+        await reader.cancel();
+        return;
+      }
+
+      index = buffer.indexOf(delimiter);
+    }
+
+    if (done) {
+      break;
+    }
+  }
+
+  buffer += decodeChunk(decoder);
+  if (flushTrailing && buffer.length > 0) {
+    onItem(normalizeItem(buffer));
+  }
+}
+
+export function consumeTextLines(
+  stream: BinaryReadableStream,
+  onLine: (line: string) => boolean | undefined,
+): Promise<void> {
+  return consumeDelimitedText(stream, '\n', onLine, {
+    normalizeItem: (line) => line.replace(/\r$/, ''),
+  });
+}
+
+export function consumeNullItems(
+  stream: BinaryReadableStream,
+  onItem: (item: string) => boolean | undefined,
+): Promise<void> {
+  return consumeDelimitedText(stream, '\0', onItem, {
+    flushTrailing: false,
+  });
+}
+
+export async function consumeNullItemsBytes(
+  stream: BinaryReadableStream,
+  onItem: (item: Uint8Array) => boolean | undefined,
+): Promise<void> {
+  const readable = toWebReadableStream(stream);
+  if (!readable) {
+    return;
+  }
+
+  const reader = readable.getReader();
+  let buffer = new Uint8Array();
+
+  while (true) {
+    const { done, value } = await reader.read();
+    if (value) {
+      const next = new Uint8Array(buffer.length + value.length);
+      next.set(buffer);
+      next.set(value, buffer.length);
+      buffer = next;
+    }
+
+    let index = buffer.indexOf(0);
+    while (index >= 0) {
+      const item = buffer.slice(0, index);
+      buffer = buffer.slice(index + 1);
+
+      if (onItem(item) === false) {
+        await reader.cancel();
+        return;
+      }
+
+      index = buffer.indexOf(0);
+    }
+
+    if (done) {
+      return;
+    }
+  }
+}
+
+export async function consumeNullCountPairs(
+  stream: BinaryReadableStream,
+  onPair: (filePath: string, countText: string) => boolean | undefined,
+): Promise<void> {
+  const readable = toWebReadableStream(stream);
+  if (!readable) {
+    return;
+  }
+
+  const reader = readable.getReader();
+  const decoder = new TextDecoder();
+  let buffer = '';
+  let currentPath: string | undefined;
+
+  while (true) {
+    const { done, value } = await reader.read();
+    buffer += decodeChunk(decoder, value);
+
+    while (true) {
+      if (currentPath === undefined) {
+        const nullIndex = buffer.indexOf('\0');
+        if (nullIndex < 0) {
+          break;
+        }
+
+        currentPath = buffer.slice(0, nullIndex);
+        buffer = buffer.slice(nullIndex + 1);
+        continue;
+      }
+
+      const newlineIndex = buffer.indexOf('\n');
+      if (newlineIndex < 0) {
+        break;
+      }
+
+      const countText = buffer.slice(0, newlineIndex).replace(/\r$/, '');
+      buffer = buffer.slice(newlineIndex + 1);
+      const path = currentPath;
+      currentPath = undefined;
+
+      if (onPair(path, countText) === false) {
+        await reader.cancel();
+        return;
+      }
+    }
+
+    if (done) {
+      break;
+    }
+  }
+
+  buffer += decodeChunk(decoder);
+}
+
+export async function consumeNullCountPairsBytes(
+  stream: BinaryReadableStream,
+  onPair: (filePath: Uint8Array, countText: string) => boolean | undefined,
+): Promise<void> {
+  const readable = toWebReadableStream(stream);
+  if (!readable) {
+    return;
+  }
+
+  const reader = readable.getReader();
+  const decoder = new TextDecoder();
+  let buffer = new Uint8Array();
+  let currentPath: Uint8Array | undefined;
+
+  while (true) {
+    const { done, value } = await reader.read();
+    if (value) {
+      const next = new Uint8Array(buffer.length + value.length);
+      next.set(buffer);
+      next.set(value, buffer.length);
+      buffer = next;
+    }
+
+    while (true) {
+      if (currentPath === undefined) {
+        const nullIndex = buffer.indexOf(0);
+        if (nullIndex < 0) {
+          break;
+        }
+
+        currentPath = buffer.slice(0, nullIndex);
+        buffer = buffer.slice(nullIndex + 1);
+        continue;
+      }
+
+      const newlineIndex = buffer.indexOf(0x0a);
+      if (newlineIndex < 0) {
+        break;
+      }
+
+      const countBytes = buffer.slice(0, newlineIndex);
+      buffer = buffer.slice(newlineIndex + 1);
+      const pathBytes = currentPath;
+      currentPath = undefined;
+      const countText = decoder.decode(countBytes).replace(/\r$/, '');
+
+      if (onPair(pathBytes, countText) === false) {
+        await reader.cancel();
+        return;
+      }
+    }
+
+    if (done) {
+      return;
+    }
+  }
+}
+
+export async function consumeRgJsonStream(
+  stream: BinaryReadableStream,
+  onEvent: (event: RgJsonEvent) => boolean | undefined,
+): Promise<void> {
+  const readable = toWebReadableStream(stream);
+  if (!readable) {
+    return;
+  }
+
+  const reader = readable.getReader();
+  const decoder = new TextDecoder();
+  let buffer = '';
+
+  while (true) {
+    const { done, value } = await reader.read();
+    buffer += decodeChunk(decoder, value);
+
+    let newlineIndex = buffer.indexOf('\n');
+    while (newlineIndex >= 0) {
+      const rawLine = buffer.slice(0, newlineIndex).replace(/\r$/, '');
+      buffer = buffer.slice(newlineIndex + 1);
+
+      if (rawLine.length > 0) {
+        let parsed: RgJsonEvent;
+        try {
+          parsed = JSON.parse(rawLine) as RgJsonEvent;
+        } catch (error) {
+          const message =
+            error instanceof Error ? error.message : String(error);
+          throw new Error(
+            `${RG_BINARY} returned invalid JSON: ${message}. Line: ${rawLine.slice(0, 200)}`,
+          );
+        }
+
+        if (onEvent(parsed) === false) {
+          await reader.cancel();
+          return;
+        }
+      }
+
+      newlineIndex = buffer.indexOf('\n');
+    }
+
+    if (done) {
+      break;
+    }
+  }
+
+  buffer += decodeChunk(decoder);
+  const trailing = buffer.replace(/\r$/, '');
+  if (trailing.length === 0) {
+    return;
+  }
+
+  try {
+    if (onEvent(JSON.parse(trailing) as RgJsonEvent) === false) {
+      await reader.cancel();
+    }
+  } catch (error) {
+    const message = error instanceof Error ? error.message : String(error);
+    throw new Error(
+      `${RG_BINARY} returned invalid trailing JSON: ${message}. Line: ${trailing.slice(0, 200)}`,
+    );
+  }
+}
+
+export async function readTextStream(
+  stream: BinaryReadableStream,
+  maxChars = MAX_STDERR_CHARS,
+): Promise<string> {
+  const readable = toWebReadableStream(stream);
+  if (!readable) {
+    return '';
+  }
+
+  const reader = readable.getReader();
+  const decoder = new TextDecoder();
+  let text = '';
+
+  while (true) {
+    const { done, value } = await reader.read();
+    text += decodeChunk(decoder, value);
+
+    if (text.length > maxChars) {
+      text = `${text.slice(0, maxChars)}\n[stderr truncated]`;
+      await reader.cancel();
+      break;
+    }
+
+    if (done) {
+      break;
+    }
+  }
+
+  return text;
+}

+ 575 - 0
src/tools/grep/mtime.ts

@@ -0,0 +1,575 @@
+import { stat } from 'node:fs/promises';
+import { MAX_MTIME_DISCOVERY_FILES } from './constants';
+import {
+  executeContentLikeMode,
+  executeCountMode,
+  executeFilesMode,
+} from './direct';
+import type { ResolvedGrepCli } from './resolver';
+import {
+  countOccurrences,
+  countVisibleMatches,
+  createEmptyResult,
+  finalizeMtimeContentResult,
+  finalizeMtimeSimpleResult,
+} from './result-utils';
+import { buildRgCommand } from './rg-args';
+import { getAbortKind, isTimedOutAbort, remainingTimeout } from './runtime';
+import type {
+  GrepFileMatch,
+  GrepSearchResult,
+  NormalizedGrepInput,
+} from './types';
+
+function formatStatError(error: unknown): string {
+  return error instanceof Error ? error.message : String(error);
+}
+
+export function buildDiscoveryInput(
+  input: NormalizedGrepInput,
+): NormalizedGrepInput {
+  return {
+    ...input,
+    outputMode: 'files_with_matches',
+    sortBy: 'none',
+    sortOrder: 'asc',
+    maxResults: MAX_MTIME_DISCOVERY_FILES,
+    maxCountPerFile: undefined,
+  };
+}
+
+async function statWithTimeout(
+  filePath: string,
+  signal: AbortSignal,
+  deadline: number,
+): Promise<
+  | { status: 'ok'; mtimeMs: number }
+  | { status: 'error'; error: string }
+  | { status: 'timed_out' }
+  | { status: 'cancelled' }
+> {
+  if (signal.aborted) {
+    return { status: isTimedOutAbort(signal) ? 'timed_out' : 'cancelled' };
+  }
+
+  const timeoutMs = remainingTimeout(deadline);
+  if (timeoutMs <= 1) {
+    return { status: 'timed_out' };
+  }
+
+  const timeoutSentinel = Symbol('grep-stat-timeout');
+  const cancelSentinel = Symbol('grep-stat-cancel');
+  let timeoutId: ReturnType<typeof setTimeout> | undefined;
+  let abortCleanup: (() => void) | undefined;
+
+  try {
+    const stats = await Promise.race([
+      stat(filePath),
+      new Promise<typeof timeoutSentinel>((resolve) => {
+        timeoutId = setTimeout(() => resolve(timeoutSentinel), timeoutMs);
+      }),
+      new Promise<typeof cancelSentinel>((resolve) => {
+        const onAbort = () => resolve(cancelSentinel);
+        signal.addEventListener('abort', onAbort, { once: true });
+        abortCleanup = () => signal.removeEventListener('abort', onAbort);
+      }),
+    ]);
+
+    if (stats === timeoutSentinel) {
+      return { status: 'timed_out' };
+    }
+
+    if (stats === cancelSentinel) {
+      return { status: isTimedOutAbort(signal) ? 'timed_out' : 'cancelled' };
+    }
+
+    return { status: 'ok', mtimeMs: stats.mtimeMs };
+  } catch (error) {
+    return { status: 'error', error: formatStatError(error) };
+  } finally {
+    if (timeoutId) {
+      clearTimeout(timeoutId);
+    }
+    abortCleanup?.();
+  }
+}
+
+async function sortFilesByMtime(
+  files: GrepFileMatch[],
+  input: Pick<NormalizedGrepInput, 'sortOrder'>,
+  signal: AbortSignal,
+  deadline: number,
+): Promise<{
+  files: GrepFileMatch[];
+  timedOut: boolean;
+  cancelled: boolean;
+  hadMore: boolean;
+  warnings: string[];
+}> {
+  const entries: Array<{
+    file: GrepFileMatch;
+    mtimeMs: number;
+    statFailed: boolean;
+  }> = [];
+  const warnings: string[] = [];
+  let index = 0;
+  let processedCount = 0;
+  let timedOut = false;
+  let cancelled = false;
+
+  const workers = Array.from(
+    { length: Math.max(1, Math.min(16, files.length)) },
+    async () => {
+      while (true) {
+        if (signal.aborted) {
+          if (isTimedOutAbort(signal)) {
+            timedOut = true;
+          } else {
+            cancelled = true;
+          }
+          return;
+        }
+
+        if (Date.now() >= deadline) {
+          timedOut = true;
+          return;
+        }
+
+        const current = index;
+        index += 1;
+        if (current >= files.length) {
+          return;
+        }
+
+        const file = files[current] as GrepFileMatch;
+        if (!file.replayPath) {
+          warnings.push(
+            `Could not stat ${file.file} for mtime ordering: non-UTF8 paths are not orderable safely.`,
+          );
+          entries.push({
+            file,
+            mtimeMs: Number.NEGATIVE_INFINITY,
+            statFailed: true,
+          });
+          processedCount += 1;
+          continue;
+        }
+
+        const statResult = await statWithTimeout(
+          file.replayPath,
+          signal,
+          deadline,
+        );
+        if (statResult.status === 'timed_out') {
+          timedOut = true;
+          return;
+        }
+
+        if (statResult.status === 'cancelled') {
+          cancelled = true;
+          return;
+        }
+
+        if (statResult.status === 'error') {
+          warnings.push(
+            `Could not stat ${file.file} for mtime ordering: ${statResult.error}`,
+          );
+          entries.push({
+            file,
+            mtimeMs: Number.NEGATIVE_INFINITY,
+            statFailed: true,
+          });
+          processedCount += 1;
+          continue;
+        }
+
+        entries.push({ file, mtimeMs: statResult.mtimeMs, statFailed: false });
+        processedCount += 1;
+      }
+    },
+  );
+
+  await Promise.all(workers);
+
+  entries.sort((left, right) => {
+    if (left.statFailed !== right.statFailed) {
+      return left.statFailed ? 1 : -1;
+    }
+
+    const delta = left.mtimeMs - right.mtimeMs;
+    if (delta !== 0) {
+      return input.sortOrder === 'desc' ? -delta : delta;
+    }
+
+    return left.file.file.localeCompare(right.file.file);
+  });
+
+  return {
+    files: entries.map((entry) => entry.file),
+    timedOut,
+    cancelled,
+    hadMore: processedCount < files.length,
+    warnings,
+  };
+}
+
+function withSearchTargets(
+  input: NormalizedGrepInput,
+  searchTargets: string[],
+  timeoutMs: number,
+  maxResults = input.maxResults,
+): NormalizedGrepInput {
+  return {
+    ...input,
+    searchPath: searchTargets[0] ?? input.searchPath,
+    requestedPath:
+      searchTargets.length === 1
+        ? (searchTargets[0] ?? input.requestedPath)
+        : input.requestedPath,
+    permissionPatterns: searchTargets,
+    maxResults,
+    timeoutMs: Math.max(1, timeoutMs),
+    sortBy: 'none',
+    sortOrder: 'asc',
+    searchTargets,
+  };
+}
+
+function chunkArray<T>(values: T[], chunkSize: number): T[][] {
+  const chunks: T[][] = [];
+  for (let index = 0; index < values.length; index += chunkSize) {
+    chunks.push(values.slice(index, index + chunkSize));
+  }
+  return chunks;
+}
+
+function reorderFilesByReplayOrder(
+  files: GrepFileMatch[],
+  orderedTargets: string[],
+): GrepFileMatch[] {
+  const order = new Map<string, number>();
+  orderedTargets.forEach((target, index) => {
+    order.set(target, index);
+  });
+
+  return [...files].sort((left, right) => {
+    const leftOrder =
+      order.get(left.replayPath ?? left.absolutePath) ??
+      Number.MAX_SAFE_INTEGER;
+    const rightOrder =
+      order.get(right.replayPath ?? right.absolutePath) ??
+      Number.MAX_SAFE_INTEGER;
+    return leftOrder - rightOrder;
+  });
+}
+
+async function discoverMatchingFiles(
+  input: NormalizedGrepInput,
+  signal: AbortSignal,
+  cli: ResolvedGrepCli,
+): Promise<GrepSearchResult> {
+  const discoveryInput = buildDiscoveryInput(input);
+  const result = await executeFilesMode(discoveryInput, signal, cli);
+
+  if (result.limitReached) {
+    result.truncated = true;
+    result.warnings.push(
+      `mtime discovery capped at ${MAX_MTIME_DISCOVERY_FILES} matching files; ordering may be partial.`,
+    );
+  }
+
+  if (result.error && result.totalFiles === 0 && result.totalMatches === 0) {
+    throw new Error(result.error);
+  }
+
+  if (result.error) {
+    result.truncated = true;
+    result.warnings.push(`Partial discovery failure: ${result.error}`);
+    result.error = undefined;
+  }
+
+  return result;
+}
+
+export async function executeMtimeMode(
+  input: NormalizedGrepInput,
+  signal: AbortSignal,
+  cli: ResolvedGrepCli,
+): Promise<GrepSearchResult> {
+  const deadline = Date.now() + input.timeoutMs;
+  const discovery = await discoverMatchingFiles(input, signal, cli);
+  const discoveryInput = buildDiscoveryInput(input);
+  const baseMtimeResult: GrepSearchResult = {
+    ...createEmptyResult(input),
+    backend: 'rg',
+  };
+  const strategyMeta = {
+    strategy: 'mtime-hybrid' as const,
+    discoveryCommand: buildRgCommand(discoveryInput, cli.path),
+    discoveredFiles: discovery.files.length,
+    mtimeDiscoveryCapped: discovery.limitReached,
+  };
+
+  const nonReplayableFiles = discovery.files.filter((file) => !file.replayPath);
+
+  if (nonReplayableFiles.length > 0) {
+    const timeoutMs = Math.max(1, remainingTimeout(deadline));
+    const fallbackInput: NormalizedGrepInput = {
+      ...input,
+      sortBy: 'none',
+      sortOrder: 'asc',
+      timeoutMs,
+    };
+    const fallback =
+      input.outputMode === 'files_with_matches'
+        ? await executeFilesMode(fallbackInput, signal, cli)
+        : input.outputMode === 'count'
+          ? await executeCountMode(fallbackInput, signal, cli)
+          : await executeContentLikeMode(fallbackInput, signal, cli);
+
+    return {
+      ...fallback,
+      strategy: 'mtime-fallback',
+      discoveryCommand: strategyMeta.discoveryCommand,
+      discoveredFiles: strategyMeta.discoveredFiles,
+      mtimeDiscoveryCapped: strategyMeta.mtimeDiscoveryCapped,
+      warnings: [
+        ...discovery.warnings,
+        `mtime ordering disabled: ${nonReplayableFiles.length} non-UTF8 ${nonReplayableFiles.length === 1 ? 'path is' : 'paths are'} not safely orderable; returned direct search results instead.`,
+        ...fallback.warnings,
+      ],
+    };
+  }
+
+  const sortedDiscovery = await sortFilesByMtime(
+    discovery.files,
+    input,
+    signal,
+    deadline,
+  );
+  const sortedFiles = sortedDiscovery.files;
+  const fullStrategyMeta = {
+    ...strategyMeta,
+    sortedFiles: sortedFiles.length,
+    replayTargetCount: sortedFiles.filter((file) => file.replayPath).length,
+  };
+
+  if (input.outputMode === 'files_with_matches') {
+    return finalizeMtimeSimpleResult(
+      input,
+      sortedFiles,
+      {
+        ...baseMtimeResult,
+        ...fullStrategyMeta,
+        truncated:
+          discovery.truncated ||
+          sortedDiscovery.hadMore ||
+          sortedDiscovery.timedOut ||
+          sortedDiscovery.cancelled,
+        timedOut: discovery.timedOut || sortedDiscovery.timedOut,
+        cancelled: discovery.cancelled || sortedDiscovery.cancelled,
+        stderr: discovery.stderr,
+        warnings: [...discovery.warnings, ...sortedDiscovery.warnings],
+        retryCount: discovery.retryCount,
+        exitCode: discovery.exitCode,
+        summary: undefined,
+        partialPhase:
+          discovery.timedOut || discovery.cancelled
+            ? 'discovery'
+            : sortedDiscovery.timedOut || sortedDiscovery.cancelled
+              ? 'mtime-sort'
+              : undefined,
+      },
+      discovery.files.length > input.maxResults,
+    );
+  }
+
+  if (
+    discovery.timedOut ||
+    discovery.cancelled ||
+    sortedDiscovery.timedOut ||
+    sortedDiscovery.cancelled
+  ) {
+    const warnings = [...discovery.warnings, ...sortedDiscovery.warnings];
+    if (discovery.files.length > 0) {
+      warnings.push(
+        `mtime ${sortedDiscovery.timedOut || sortedDiscovery.cancelled ? 'sorting' : 'discovery'} stopped after discovering ${discovery.files.length} candidate ${discovery.files.length === 1 ? 'file' : 'files'}.`,
+      );
+    }
+
+    return {
+      ...baseMtimeResult,
+      ...fullStrategyMeta,
+      truncated: true,
+      timedOut: discovery.timedOut || sortedDiscovery.timedOut,
+      cancelled: discovery.cancelled || sortedDiscovery.cancelled,
+      stderr: discovery.stderr,
+      warnings,
+      retryCount: discovery.retryCount,
+      exitCode: discovery.exitCode,
+      summary: undefined,
+      partialPhase:
+        discovery.timedOut || discovery.cancelled ? 'discovery' : 'mtime-sort',
+    };
+  }
+
+  const collected: GrepFileMatch[] = [];
+  let timedOut = false;
+  let cancelled = false;
+  let limitReached = false;
+  let partialReplayFailure = false;
+  let stderr = discovery.stderr;
+  const warnings: string[] = [
+    ...discovery.warnings,
+    ...sortedDiscovery.warnings,
+  ];
+  let retryCount = discovery.retryCount;
+  let exitCode = discovery.exitCode;
+  let replayBatchCount = 0;
+  let replayedFiles = 0;
+  const replayableFiles = sortedFiles.filter((file) => {
+    if (file.replayPath) {
+      return true;
+    }
+    partialReplayFailure = true;
+    warnings.push(
+      `Skipped ${file.file} during mtime replay: non-UTF8 paths are not replayable safely.`,
+    );
+    return false;
+  });
+
+  const replayBatchSize = input.outputMode === 'content' ? 1 : 64;
+
+  for (const batch of chunkArray(replayableFiles, replayBatchSize)) {
+    if (signal.aborted) {
+      if (getAbortKind(signal) === 'timeout') {
+        timedOut = true;
+      } else {
+        cancelled = true;
+      }
+      break;
+    }
+
+    const timeoutMs = remainingTimeout(deadline);
+    if (timeoutMs <= 1) {
+      timedOut = true;
+      break;
+    }
+
+    const orderedTargets = batch
+      .map((file) => file.replayPath)
+      .filter((value): value is string => Boolean(value));
+    if (orderedTargets.length === 0) {
+      continue;
+    }
+
+    const scopedInput = withSearchTargets(
+      input,
+      orderedTargets,
+      timeoutMs,
+      input.outputMode === 'count'
+        ? Number.MAX_SAFE_INTEGER
+        : Math.max(1, input.maxResults - countVisibleMatches(collected)),
+    );
+    replayBatchCount += 1;
+    const partial =
+      input.outputMode === 'count'
+        ? await executeCountMode(scopedInput, signal, cli)
+        : await executeContentLikeMode(scopedInput, signal, cli);
+
+    retryCount += partial.retryCount;
+    exitCode = Math.max(exitCode, partial.exitCode);
+    if (partial.stderr) {
+      stderr = partial.stderr;
+    }
+    warnings.push(...partial.warnings);
+    if (partial.error) {
+      partialReplayFailure = true;
+      warnings.push(
+        `Skipped mtime replay batch ${replayBatchCount}: ${partial.error}`,
+      );
+      continue;
+    }
+
+    timedOut = timedOut || partial.timedOut;
+    cancelled = cancelled || partial.cancelled;
+    limitReached = limitReached || partial.limitReached;
+    const reordered = reorderFilesByReplayOrder(partial.files, orderedTargets);
+
+    if (input.outputMode === 'count') {
+      collected.push(...reordered);
+      replayedFiles += reordered.length;
+
+      if (collected.length >= input.maxResults) {
+        limitReached = true;
+      }
+
+      if (limitReached || timedOut || cancelled) {
+        break;
+      }
+
+      continue;
+    }
+
+    collected.push(...reordered);
+    replayedFiles += reordered.length;
+
+    const visibleMatches = countVisibleMatches(collected);
+    if (visibleMatches >= input.maxResults) {
+      limitReached = true;
+    }
+
+    if (limitReached || timedOut || cancelled) {
+      break;
+    }
+  }
+
+  const base = createEmptyResult(input);
+  const partialBase: GrepSearchResult = {
+    ...base,
+    ...fullStrategyMeta,
+    files: collected,
+    totalMatches:
+      input.outputMode === 'count'
+        ? countOccurrences(collected)
+        : countVisibleMatches(collected),
+    totalFiles: collected.length,
+    truncated:
+      discovery.truncated ||
+      sortedDiscovery.timedOut ||
+      sortedDiscovery.cancelled ||
+      sortedDiscovery.hadMore ||
+      timedOut ||
+      cancelled ||
+      limitReached ||
+      partialReplayFailure,
+    limitReached,
+    timedOut,
+    cancelled,
+    stderr,
+    warnings,
+    retryCount,
+    exitCode,
+    summary: undefined,
+    replayBatchCount,
+    replayedFiles,
+    partialPhase:
+      timedOut || cancelled || partialReplayFailure ? 'replay' : undefined,
+  };
+
+  if (input.outputMode === 'count') {
+    return finalizeMtimeSimpleResult(
+      input,
+      collected,
+      partialBase,
+      limitReached || collected.length > input.maxResults,
+    );
+  }
+
+  return finalizeMtimeContentResult(
+    input,
+    collected,
+    partialBase,
+    limitReached,
+  );
+}

+ 324 - 0
src/tools/grep/normalize.test.ts

@@ -0,0 +1,324 @@
+/// <reference types="bun-types" />
+import { describe, expect, test } from 'bun:test';
+import { symlinkSync, writeFileSync } from 'node:fs';
+import path from 'node:path';
+import { DEFAULT_GREP_LIMIT, DEFAULT_GREP_TIMEOUT_MS } from './constants';
+import { normalizeGrepInput } from './normalize';
+import { buildRgArgs } from './rg-args';
+import { createRepoContext, createTempTracker } from './test-helpers';
+
+describe('tools/grep/normalize', () => {
+  const temps = createTempTracker();
+
+  function createNormalized(
+    input: any,
+    options: { directory?: string; worktree?: string } = {},
+  ) {
+    const repoDir = options.worktree ?? temps.createRepo();
+    const directory = options.directory ?? repoDir;
+    const worktree = options.worktree ?? repoDir;
+
+    return {
+      repoDir,
+      normalized: normalizeGrepInput(
+        input,
+        createRepoContext(directory, worktree) as any,
+      ),
+    };
+  }
+
+  test('normalizes defaults while keeping base grep fields compatible', () => {
+    const { repoDir, normalized } = createNormalized({
+      pattern: 'createTool',
+      path: 'src',
+      include: '*.ts',
+    });
+
+    expect(normalized.pattern).toBe('createTool');
+    expect(normalized.searchPath).toBe(path.join(repoDir, 'src'));
+    expect(normalized.include).toBe('*.ts');
+    expect(normalized.outputMode).toBe('content');
+    expect(normalized.caseSensitive).toBe(true);
+    expect(normalized.context).toBe(0);
+    expect(normalized.beforeContext).toBe(0);
+    expect(normalized.afterContext).toBe(0);
+    expect(normalized.maxResults).toBe(DEFAULT_GREP_LIMIT);
+    expect(normalized.maxCountPerFile).toBeUndefined();
+    expect(normalized.timeoutMs).toBe(DEFAULT_GREP_TIMEOUT_MS);
+    expect(normalized.hidden).toBe(true);
+    expect(normalized.invertMatch).toBe(false);
+    expect(normalized.fileTypes).toEqual([]);
+    expect(normalized.excludeFileTypes).toEqual([]);
+  });
+
+  test('resolves relative path against current directory when it differs from worktree', () => {
+    const repoDir = temps.createRepo();
+    const parentDir = path.dirname(repoDir);
+
+    const normalized = normalizeGrepInput(
+      {
+        pattern: 'createTool',
+        path: path.basename(repoDir),
+      },
+      createRepoContext(parentDir, repoDir) as any,
+    );
+
+    expect(normalized.searchPath).toBe(repoDir);
+    expect(normalized.resolvedPath).toBe(repoDir);
+    expect(normalized.worktree).toBe(repoDir);
+  });
+
+  test('resolves symlink search paths to their real path for execution and permissions', () => {
+    const repoDir = temps.createRepo();
+    const externalDir = temps.createDir('oh-my-opencode-grep-ext');
+    writeFileSync(path.join(externalDir, 'outside.ts'), 'createTool\n');
+    symlinkSync(externalDir, path.join(repoDir, 'linked-outside'), 'dir');
+
+    const normalized = normalizeGrepInput(
+      {
+        pattern: 'createTool',
+        path: 'linked-outside',
+      },
+      createRepoContext(repoDir) as any,
+    );
+
+    expect(normalized.resolvedPath).toBe(path.join(repoDir, 'linked-outside'));
+    expect(normalized.searchPath).toBe(externalDir);
+    expect(normalized.permissionPatterns).toEqual([externalDir]);
+  });
+
+  test('fails cleanly for dangling symlink search paths', () => {
+    const repoDir = temps.createRepo();
+    const dangling = path.join(repoDir, 'dangling');
+    symlinkSync(path.join(repoDir, 'missing-target'), dangling);
+
+    expect(() =>
+      normalizeGrepInput(
+        {
+          pattern: 'createTool',
+          path: 'dangling',
+        },
+        createRepoContext(repoDir) as any,
+      ),
+    ).toThrow(/Search path does not exist|Failed to resolve search path/);
+  });
+
+  test('normalizes advanced glob and engine options', () => {
+    const { normalized } = createNormalized({
+      pattern: 'createTool',
+      path: 'src',
+      globs: ['*.ts', '!**/*.test.ts'],
+      exclude_globs: ['dist/**'],
+      smart_case: true,
+      pcre2: true,
+      max_filesize: '1M',
+      sort_by: 'path',
+      sort_order: 'desc',
+    });
+
+    expect(normalized.globs).toEqual(['*.ts', '!**/*.test.ts']);
+    expect(normalized.excludeGlobs).toEqual(['dist/**']);
+    expect(normalized.smartCase).toBe(true);
+    expect(normalized.pcre2).toBe(true);
+    expect(normalized.maxFilesize).toBe('1M');
+    expect(normalized.sortBy).toBe('path');
+    expect(normalized.sortOrder).toBe('desc');
+  });
+
+  test('explicit case_sensitive=false disables smart_case heuristics', () => {
+    const { normalized } = createNormalized({
+      pattern: 'CreateTool',
+      path: 'src',
+      smart_case: true,
+      case_sensitive: false,
+    });
+
+    const args = buildRgArgs(normalized);
+    expect(normalized.caseSensitive).toBe(false);
+    expect(normalized.smartCase).toBe(false);
+    expect(args).toEqual(expect.arrayContaining(['-i']));
+    expect(args).not.toContain('--smart-case');
+  });
+
+  test('auto-enables multiline when multiline_dotall is requested', () => {
+    const { normalized } = createNormalized({
+      pattern: 'foo.*bar',
+      path: 'src',
+      multiline_dotall: true,
+    });
+
+    expect(normalized.multiline).toBe(true);
+    expect(normalized.multilineDotall).toBe(true);
+    expect(buildRgArgs(normalized)).toEqual(
+      expect.arrayContaining(['--multiline', '--multiline-dotall']),
+    );
+  });
+
+  test.each([
+    {
+      name: 'defaults mtime ordering to descending recency',
+      input: { pattern: 'createTool', path: 'src', sort_by: 'mtime' },
+      expected: { sortBy: 'mtime', sortOrder: 'desc' },
+    },
+    {
+      name: 'defaults non-mtime ordering to ascending when omitted',
+      input: { pattern: 'createTool', path: 'src', sort_by: 'path' },
+      expected: { sortBy: 'path', sortOrder: 'asc' },
+    },
+  ])('$name', ({ input, expected }) => {
+    const { normalized } = createNormalized(input);
+    expect(normalized.sortBy).toBe(expected.sortBy);
+    expect(normalized.sortOrder).toBe(expected.sortOrder);
+  });
+
+  test('normalizes asymmetric context and multiple file type filters', () => {
+    const { normalized } = createNormalized({
+      pattern: 'createTool',
+      path: 'src',
+      context: 3,
+      before_context: 1,
+      file_type: 'ts',
+      file_types: ['tsx', 'ts', 'js'],
+      exclude_file_types: ['md', 'json', 'md'],
+      invert_match: true,
+      max_count_per_file: 7,
+    });
+
+    expect(normalized.context).toBe(3);
+    expect(normalized.beforeContext).toBe(1);
+    expect(normalized.afterContext).toBe(3);
+    expect(normalized.fileType).toBe('ts');
+    expect(normalized.fileTypes).toEqual(['ts', 'tsx', 'js']);
+    expect(normalized.excludeFileTypes).toEqual(['md', 'json']);
+    expect(normalized.invertMatch).toBe(true);
+    expect(normalized.maxCountPerFile).toBe(7);
+  });
+
+  test('builds ripgrep args for advanced options', () => {
+    const { normalized } = createNormalized({
+      pattern: 'createTool',
+      path: 'src',
+      output_mode: 'content',
+      globs: ['*.ts'],
+      exclude_globs: ['dist/**'],
+      smart_case: true,
+      pcre2: true,
+      max_filesize: '1M',
+      sort_by: 'path',
+      sort_order: 'desc',
+    });
+
+    const args = buildRgArgs(normalized);
+    expect(args).toContain('--smart-case');
+    expect(args).toContain('--pcre2');
+    expect(args).toContain('--max-filesize');
+    expect(args).toContain('1M');
+    expect(args).toContain('--sortr');
+    expect(args).toContain('path');
+    expect(args).toContain('!dist/**');
+  });
+
+  test('builds ripgrep args for asymmetric context and file type filters', () => {
+    const { normalized } = createNormalized({
+      pattern: 'createTool',
+      path: 'src',
+      output_mode: 'content',
+      context: 4,
+      before_context: 1,
+      after_context: 2,
+      file_type: 'ts',
+      file_types: ['tsx'],
+      exclude_file_types: ['md'],
+      invert_match: true,
+      max_count_per_file: 5,
+    });
+
+    const args = buildRgArgs(normalized);
+    expect(args).toEqual(
+      expect.arrayContaining([
+        '-B',
+        '1',
+        '-A',
+        '2',
+        '--type',
+        'ts',
+        '--type',
+        'tsx',
+        '--type-not',
+        'md',
+        '--invert-match',
+        '--max-count',
+        '5',
+      ]),
+    );
+    expect(args).not.toContain('-C');
+  });
+
+  test('builds count/files args with NUL delimiters and engine flags', () => {
+    const { normalized: countInput } = createNormalized({
+      pattern: 'createTool',
+      path: 'src',
+      output_mode: 'count',
+      fixed_strings: true,
+      follow_symlinks: true,
+      multiline: true,
+    });
+    const countArgs = buildRgArgs(countInput);
+    expect(countArgs).toEqual(
+      expect.arrayContaining([
+        '--null',
+        '--count-matches',
+        '--fixed-strings',
+        '--follow',
+        '--multiline',
+      ]),
+    );
+    expect(countArgs).not.toContain('--multiline-dotall');
+
+    const { normalized: dotallInput } = createNormalized({
+      pattern: 'createTool',
+      path: 'src',
+      output_mode: 'count',
+      multiline: true,
+      multiline_dotall: true,
+    });
+    expect(buildRgArgs(dotallInput)).toEqual(
+      expect.arrayContaining(['--multiline', '--multiline-dotall']),
+    );
+
+    const { normalized: filesInput } = createNormalized({
+      pattern: 'createTool',
+      path: 'src',
+      output_mode: 'files_with_matches',
+    });
+    expect(buildRgArgs(filesInput)).toEqual(
+      expect.arrayContaining(['--null', '--files-with-matches']),
+    );
+  });
+
+  test('uses symmetric -C context when effective before and after match', () => {
+    const { normalized } = createNormalized({
+      pattern: 'createTool',
+      path: 'src',
+      context: 2,
+    });
+
+    const args = buildRgArgs(normalized);
+    expect(args).toContain('-C');
+    expect(args).toContain('2');
+    expect(args).not.toContain('-B');
+    expect(args).not.toContain('-A');
+  });
+
+  test('builds files_with_matches args without json mode', () => {
+    const { normalized } = createNormalized({
+      pattern: 'createTool',
+      path: 'src',
+      output_mode: 'files_with_matches',
+    });
+
+    const args = buildRgArgs(normalized);
+    expect(args).toContain('--files-with-matches');
+    expect(args).not.toContain('--json');
+  });
+});

+ 191 - 0
src/tools/grep/normalize.ts

@@ -0,0 +1,191 @@
+import { existsSync, realpathSync, statSync } from 'node:fs';
+import path from 'node:path';
+import type { PluginInput, ToolContext } from '@opencode-ai/plugin';
+import {
+  DEFAULT_GREP_CONTEXT,
+  DEFAULT_GREP_LIMIT,
+  DEFAULT_GREP_TIMEOUT_MS,
+  MAX_GREP_CONTEXT,
+  MAX_GREP_LIMIT,
+  MAX_GREP_TIMEOUT_MS,
+} from './constants';
+import type { GrepToolInput, NormalizedGrepInput } from './types';
+
+function clampInteger(
+  value: number | undefined,
+  fallback: number,
+  min: number,
+  max: number,
+): number {
+  if (!Number.isFinite(value)) {
+    return fallback;
+  }
+
+  const normalized = Math.trunc(value as number);
+  return Math.min(Math.max(normalized, min), max);
+}
+
+function cleanOptionalString(value: unknown): string | undefined {
+  if (typeof value !== 'string') {
+    return undefined;
+  }
+
+  return value.trim().length > 0 ? value : undefined;
+}
+
+function cleanStringArray(value: unknown): string[] {
+  if (!Array.isArray(value)) {
+    return [];
+  }
+
+  return value
+    .map((item) => cleanOptionalString(item))
+    .filter((item): item is string => Boolean(item));
+}
+
+function uniqueStrings(values: Iterable<string | undefined>): string[] {
+  const seen = new Set<string>();
+  const normalized: string[] = [];
+
+  for (const value of values) {
+    if (!value || seen.has(value)) {
+      continue;
+    }
+
+    seen.add(value);
+    normalized.push(value);
+  }
+
+  return normalized;
+}
+
+function cleanOptionalPositiveInteger(
+  value: number | undefined,
+): number | undefined {
+  if (!Number.isFinite(value)) {
+    return undefined;
+  }
+
+  const normalized = Math.trunc(value as number);
+  return normalized > 0 ? normalized : undefined;
+}
+
+export function normalizeGrepInput(
+  args: GrepToolInput,
+  context: Pick<ToolContext, 'directory' | 'worktree'>,
+  pluginCtx?: Pick<PluginInput, 'directory' | 'worktree'>,
+): NormalizedGrepInput {
+  if (typeof args.pattern !== 'string' || args.pattern.length === 0) {
+    throw new Error('pattern must be a non-empty string');
+  }
+
+  const cwd = context.directory || pluginCtx?.directory || process.cwd();
+  const worktree =
+    context.worktree || pluginCtx?.worktree || context.directory || cwd;
+  const base = cwd;
+  const requestedPath = cleanOptionalString(args.path) ?? '.';
+  const resolvedPath = path.isAbsolute(requestedPath)
+    ? requestedPath
+    : path.resolve(base, requestedPath);
+  const include = cleanOptionalString(args.include);
+  const globs = cleanStringArray(args.globs);
+  const excludeGlobs = cleanStringArray(args.exclude_globs);
+  const contextValue = clampInteger(
+    args.context,
+    DEFAULT_GREP_CONTEXT,
+    0,
+    MAX_GREP_CONTEXT,
+  );
+  const beforeContext = clampInteger(
+    args.before_context,
+    contextValue,
+    0,
+    MAX_GREP_CONTEXT,
+  );
+  const afterContext = clampInteger(
+    args.after_context,
+    contextValue,
+    0,
+    MAX_GREP_CONTEXT,
+  );
+  const fileType = cleanOptionalString(args.file_type);
+  const fileTypes = uniqueStrings([
+    fileType,
+    ...cleanStringArray(args.file_types),
+  ]);
+  const excludeFileTypes = uniqueStrings(
+    cleanStringArray(args.exclude_file_types),
+  );
+
+  if (!existsSync(resolvedPath)) {
+    throw new Error(`Search path does not exist: ${requestedPath}`);
+  }
+
+  let searchPath: string;
+  try {
+    searchPath = realpathSync.native
+      ? realpathSync.native(resolvedPath)
+      : realpathSync(resolvedPath);
+  } catch (error) {
+    throw new Error(
+      `Failed to resolve search path: ${requestedPath} (${error instanceof Error ? error.message : String(error)})`,
+    );
+  }
+  const searchStat = statSync(searchPath);
+  const multilineDotall = args.multiline_dotall === true;
+  const multiline = args.multiline === true || multilineDotall;
+  const caseSensitive = args.case_sensitive !== false;
+  const smartCase = caseSensitive && args.smart_case === true;
+
+  if (!searchStat.isFile() && !searchStat.isDirectory()) {
+    throw new Error(
+      `Search path must be a file or directory: ${requestedPath}`,
+    );
+  }
+
+  return {
+    pattern: args.pattern,
+    requestedPath,
+    resolvedPath,
+    searchPath,
+    include,
+    globs,
+    excludeGlobs,
+    outputMode: args.output_mode ?? 'content',
+    caseSensitive,
+    smartCase,
+    wordRegexp: args.word_regexp === true,
+    context: contextValue,
+    beforeContext,
+    afterContext,
+    maxResults: clampInteger(
+      args.max_results,
+      DEFAULT_GREP_LIMIT,
+      1,
+      MAX_GREP_LIMIT,
+    ),
+    maxCountPerFile: cleanOptionalPositiveInteger(args.max_count_per_file),
+    timeoutMs: clampInteger(
+      args.timeout_ms,
+      DEFAULT_GREP_TIMEOUT_MS,
+      1,
+      MAX_GREP_TIMEOUT_MS,
+    ),
+    hidden: args.hidden !== false,
+    followSymlinks: args.follow_symlinks === true,
+    fixedStrings: args.fixed_strings === true,
+    invertMatch: args.invert_match === true,
+    multiline,
+    multilineDotall,
+    pcre2: args.pcre2 === true,
+    fileType,
+    fileTypes,
+    excludeFileTypes,
+    maxFilesize: cleanOptionalString(args.max_filesize),
+    sortBy: args.sort_by ?? 'none',
+    sortOrder: args.sort_order ?? (args.sort_by === 'mtime' ? 'desc' : 'asc'),
+    cwd,
+    worktree,
+    permissionPatterns: [searchPath],
+  };
+}

+ 363 - 0
src/tools/grep/path-utils.ts

@@ -0,0 +1,363 @@
+import path from 'node:path';
+
+export function tryDecodeUtf8(bytes: Uint8Array): string | undefined {
+  try {
+    return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
+  } catch {
+    return undefined;
+  }
+}
+
+export function encodeBytesIdentity(bytes: Uint8Array): string {
+  return `bytes:base64:${Buffer.from(bytes).toString('base64')}`;
+}
+
+export function getDisplayPath(absolutePath: string, worktree: string): string {
+  const root = path.parse(worktree).root;
+  if (worktree === root) {
+    return absolutePath;
+  }
+
+  const relative = path.relative(worktree, absolutePath);
+  if (
+    relative.length > 0 &&
+    !relative.startsWith('..') &&
+    !path.isAbsolute(relative)
+  ) {
+    return relative;
+  }
+
+  return absolutePath;
+}
+
+export function resolveAbsolutePath(filePath: string, cwd: string): string {
+  return path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
+}
+
+export function stripSingleLineEnding(value: string): string {
+  return value.replace(/\r$/, '');
+}
+
+export function normalizeDisplayText(value: string): string {
+  return value.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
+}
+
+export function sanitizeTitle(value: string, maxLength = 160): string {
+  const singleLine = escapeControlChars(
+    normalizeDisplayText(value).replace(/\s+/g, ' ').trim(),
+  );
+  if (singleLine.length <= maxLength) {
+    return singleLine;
+  }
+
+  return `${singleLine.slice(0, Math.max(1, maxLength - 3))}...`;
+}
+
+export function escapeControlChars(value: string): string {
+  let output = '';
+  for (const char of value) {
+    const code = char.charCodeAt(0);
+    if (code === 0x0d) {
+      output += '\\r';
+    } else if (code === 0x0a) {
+      output += '\\n';
+    } else if (code === 0x09) {
+      output += '\\t';
+    } else if (code < 0x20 || code === 0x7f) {
+      output += `\\x${code.toString(16).padStart(2, '0')}`;
+    } else {
+      output += char;
+    }
+  }
+  return output;
+}
+
+export function escapeControlCharsPreservingNewlines(value: string): string {
+  let output = '';
+  for (const char of value) {
+    const code = char.charCodeAt(0);
+    if (code === 0x0d) {
+      output += '\\r';
+    } else if (code === 0x09) {
+      output += '\\t';
+    } else if ((code < 0x20 || code === 0x7f) && code !== 0x0a) {
+      output += `\\x${code.toString(16).padStart(2, '0')}`;
+    } else {
+      output += char;
+    }
+  }
+  return output;
+}
+
+export function escapePathBytes(bytes: Uint8Array): string {
+  let output = '';
+  for (const byte of bytes) {
+    if (byte === 0x0a) {
+      output += '\\n';
+      continue;
+    }
+    if (byte === 0x0d) {
+      output += '\\r';
+      continue;
+    }
+    if (byte === 0x09) {
+      output += '\\t';
+      continue;
+    }
+    if (byte >= 0x20 && byte <= 0x7e) {
+      output += String.fromCharCode(byte);
+      continue;
+    }
+    output += `\\x${byte.toString(16).padStart(2, '0')}`;
+  }
+  return output;
+}
+
+export function escapeBinaryText(bytes: Uint8Array): string {
+  let output = '';
+  for (const byte of bytes) {
+    if (byte === 0x0a) {
+      output += '\n';
+      continue;
+    }
+    if (byte === 0x0d) {
+      output += '\r';
+      continue;
+    }
+    if (byte === 0x09) {
+      output += '\t';
+      continue;
+    }
+    if (byte >= 0x20 && byte <= 0x7e) {
+      output += String.fromCharCode(byte);
+      continue;
+    }
+    output += `\\x${byte.toString(16).padStart(2, '0')}`;
+  }
+  return output;
+}
+
+export function formatNonUtf8TextDisplay(bytes: Uint8Array): string {
+  return `${escapeBinaryText(bytes)} [${encodeBytesIdentity(bytes)}]`;
+}
+
+export function formatNonUtf8PathDisplay(
+  displayBytes: Uint8Array,
+  identityBytes = displayBytes,
+): string {
+  return `${escapePathBytes(displayBytes)} [${encodeBytesIdentity(identityBytes)}]`;
+}
+
+function startsWithBytes(bytes: Uint8Array, prefix: Uint8Array): boolean {
+  if (prefix.length > bytes.length) {
+    return false;
+  }
+  for (let index = 0; index < prefix.length; index += 1) {
+    if (bytes[index] !== prefix[index]) {
+      return false;
+    }
+  }
+  return true;
+}
+
+type BytePathStyle = 'posix' | 'windows';
+
+function looksWindowsText(value: string): boolean {
+  return /^[a-zA-Z]:[\\/]/.test(value) || value.startsWith('\\\\');
+}
+
+function getBytePathStyle(
+  rawBytes: Uint8Array,
+  cwd: string,
+  worktree: string,
+): BytePathStyle {
+  if (looksWindowsText(cwd) || looksWindowsText(worktree)) {
+    return 'windows';
+  }
+
+  if (
+    isAlphaByte(rawBytes[0]) &&
+    rawBytes[1] === 0x3a &&
+    (rawBytes[2] === 0x2f || rawBytes[2] === 0x5c)
+  ) {
+    return 'windows';
+  }
+
+  if (rawBytes[0] === 0x5c && rawBytes[1] === 0x5c) {
+    return 'windows';
+  }
+
+  return 'posix';
+}
+
+function isSepByte(byte: number | undefined, style: BytePathStyle): boolean {
+  if (style === 'windows') {
+    return byte === 0x2f || byte === 0x5c;
+  }
+
+  return byte === 0x2f;
+}
+
+function isAlphaByte(byte: number | undefined): boolean {
+  return (
+    byte !== undefined &&
+    ((byte >= 0x41 && byte <= 0x5a) || (byte >= 0x61 && byte <= 0x7a))
+  );
+}
+
+function isAbsoluteBytes(bytes: Uint8Array, style: BytePathStyle): boolean {
+  if (bytes.length === 0) {
+    return false;
+  }
+
+  if (style === 'posix') {
+    return bytes[0] === 0x2f;
+  }
+
+  if (isSepByte(bytes[0], style)) {
+    return true;
+  }
+
+  return (
+    isAlphaByte(bytes[0]) && bytes[1] === 0x3a && isSepByte(bytes[2], style)
+  );
+}
+
+function pickSeparatorByte(
+  base: Uint8Array,
+  value: Uint8Array,
+  style: BytePathStyle,
+): number {
+  if (style === 'windows' && (base.includes(0x5c) || value.includes(0x5c))) {
+    return 0x5c;
+  }
+
+  return 0x2f;
+}
+
+function isRootPathText(value: string, style: BytePathStyle): boolean {
+  if (style === 'windows') {
+    return (
+      /^[a-zA-Z]:[\\/]$/.test(value) ||
+      /^\\\\[^\\/]+[\\/][^\\/]+[\\/]?$/.test(value)
+    );
+  }
+
+  return value === '/';
+}
+
+function trimTrailingSeparators(value: string, style: BytePathStyle): string {
+  if (value.length === 0 || isRootPathText(value, style)) {
+    return value;
+  }
+
+  let end = value.length;
+  while (end > 0) {
+    const char = value[end - 1];
+    const isSep =
+      style === 'windows' ? char === '/' || char === '\\' : char === '/';
+    if (!isSep) {
+      break;
+    }
+
+    const candidate = value.slice(0, end);
+    if (isRootPathText(candidate, style)) {
+      break;
+    }
+
+    end -= 1;
+  }
+
+  return value.slice(0, end);
+}
+
+function hasPathPrefixBytes(
+  bytes: Uint8Array,
+  prefix: Uint8Array,
+  style: BytePathStyle,
+): boolean {
+  if (!startsWithBytes(bytes, prefix)) {
+    return false;
+  }
+
+  if (bytes.length === prefix.length) {
+    return true;
+  }
+
+  if (isSepByte(prefix[prefix.length - 1], style)) {
+    return true;
+  }
+
+  return isSepByte(bytes[prefix.length], style);
+}
+
+function joinPathBytes(
+  base: Uint8Array,
+  value: Uint8Array,
+  style: BytePathStyle,
+): Uint8Array {
+  if (base.length === 0) {
+    return Buffer.from(value);
+  }
+
+  if (isSepByte(base[base.length - 1], style)) {
+    return Buffer.concat([Buffer.from(base), Buffer.from(value)]);
+  }
+
+  return Buffer.concat([
+    Buffer.from(base),
+    Buffer.from([pickSeparatorByte(base, value, style)]),
+    Buffer.from(value),
+  ]);
+}
+
+export function buildPathFromBytes(
+  rawBytes: Uint8Array,
+  cwd: string,
+  worktree: string,
+): {
+  absolutePath: string;
+  displayPath: string;
+  replayPath?: string;
+  nonUtf8Path: boolean;
+  pathKey: string;
+} {
+  const decoded = tryDecodeUtf8(rawBytes);
+  if (decoded !== undefined) {
+    const absolutePath = resolveAbsolutePath(decoded, cwd);
+    return {
+      absolutePath,
+      displayPath: getDisplayPath(absolutePath, worktree),
+      replayPath: absolutePath,
+      nonUtf8Path: false,
+      pathKey: `utf8:${absolutePath}`,
+    };
+  }
+
+  const style = getBytePathStyle(rawBytes, cwd, worktree);
+  const normalizedCwd = trimTrailingSeparators(cwd, style);
+  const normalizedWorktree = trimTrailingSeparators(worktree, style);
+  const cwdBytes = Buffer.from(normalizedCwd);
+  const absoluteBytes = isAbsoluteBytes(rawBytes, style)
+    ? Buffer.from(rawBytes)
+    : joinPathBytes(cwdBytes, rawBytes, style);
+  const worktreeBytes = Buffer.from(normalizedWorktree);
+  const absoluteIdentity = encodeBytesIdentity(absoluteBytes);
+  const worktreeRoot = isRootPathText(normalizedWorktree, style);
+  const relativeBytes = hasPathPrefixBytes(absoluteBytes, worktreeBytes, style)
+    ? absoluteBytes.subarray(
+        worktreeBytes.length +
+          (isSepByte(absoluteBytes[worktreeBytes.length], style) ? 1 : 0),
+      )
+    : absoluteBytes;
+  const displayPath = worktreeRoot
+    ? formatNonUtf8PathDisplay(absoluteBytes, absoluteBytes)
+    : formatNonUtf8PathDisplay(relativeBytes, absoluteBytes);
+
+  return {
+    absolutePath: absoluteIdentity,
+    displayPath,
+    nonUtf8Path: true,
+    pathKey: absoluteIdentity,
+  };
+}

+ 193 - 0
src/tools/grep/path.test.ts

@@ -0,0 +1,193 @@
+/// <reference types="bun-types" />
+import { describe, expect, test } from 'bun:test';
+import path from 'node:path';
+import { decodeRgPayload } from './json-stream';
+import { buildPathFromBytes, sanitizeTitle } from './path-utils';
+import { createTempTracker } from './test-helpers';
+
+describe('tools/grep/path', () => {
+  const temps = createTempTracker();
+
+  test('buildPathFromBytes preserves non-UTF8 paths without lossy replacement', () => {
+    const repoDir = temps.createRepo();
+    const raw = Uint8Array.from([0x73, 0x72, 0x63, 0x2f, 0x66, 0x6f, 0x80]);
+    const info = buildPathFromBytes(raw, repoDir, repoDir);
+
+    expect(info.nonUtf8Path).toBe(true);
+    expect(info.replayPath).toBeUndefined();
+    expect(info.displayPath).toContain('src/fo');
+    expect(info.displayPath).toContain('\\x80');
+    expect(info.displayPath).toContain('[bytes:base64:');
+    expect(info.absolutePath).toMatch(/^bytes:base64:/);
+    expect(info.pathKey).toBe(info.absolutePath);
+  });
+
+  test.each([
+    {
+      name: 'keeps absolute display paths when worktree is filesystem root',
+      raw: Uint8Array.from([
+        0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x62, 0x61, 0x64, 0x80,
+      ]),
+      cwd: () => temps.createRepo(),
+      worktree: (repoDir: string) => path.parse(repoDir).root,
+      assertInfo(info: ReturnType<typeof buildPathFromBytes>) {
+        expect(info.displayPath.startsWith('/tmp/bad')).toBe(true);
+        expect(info.displayPath).toContain('\\x80');
+        expect(info.displayPath).toContain('[bytes:base64:');
+      },
+    },
+    {
+      name: 'does not relativize unrelated paths that only share a prefix',
+      raw: Uint8Array.from([
+        0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x72, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74,
+        0x6f, 0x72, 0x79, 0x2f, 0x66, 0x6f, 0x80,
+      ]),
+      cwd: () => '/tmp/repo',
+      worktree: () => '/tmp/repo',
+      assertInfo(info: ReturnType<typeof buildPathFromBytes>) {
+        expect(info.displayPath.startsWith('/tmp/repository/fo')).toBe(true);
+        expect(info.displayPath).toContain('\\x80');
+        expect(info.displayPath).toContain('[bytes:base64:');
+      },
+    },
+    {
+      name: 'treats leading backslash as relative on POSIX',
+      raw: Uint8Array.from([0x5c, 0x66, 0x6f, 0x80]),
+      cwd: () => '/tmp/repo',
+      worktree: () => '/tmp/repo',
+      assertInfo(info: ReturnType<typeof buildPathFromBytes>) {
+        expect(info.displayPath).toBe(
+          '\\fo\\x80 [bytes:base64:L3RtcC9yZXBvL1xmb4A=]',
+        );
+        expect(info.absolutePath).toBe('bytes:base64:L3RtcC9yZXBvL1xmb4A=');
+      },
+    },
+    {
+      name: 'keeps POSIX backslashes as normal bytes inside names',
+      raw: Uint8Array.from([0x73, 0x72, 0x63, 0x5c, 0x66, 0x6f, 0x6f, 0x80]),
+      cwd: () => '/tmp/repo',
+      worktree: () => '/tmp/repo',
+      assertInfo(info: ReturnType<typeof buildPathFromBytes>) {
+        expect(info.displayPath).toBe(
+          'src\\foo\\x80 [bytes:base64:L3RtcC9yZXBvL3NyY1xmb2+A]',
+        );
+        expect(info.absolutePath).toBe('bytes:base64:L3RtcC9yZXBvL3NyY1xmb2+A');
+      },
+    },
+    {
+      name: 'does not relativize POSIX paths with backslash after prefix',
+      raw: Uint8Array.from([
+        0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x72, 0x65, 0x70, 0x6f, 0x5c, 0x65, 0x76,
+        0x69, 0x6c, 0x80,
+      ]),
+      cwd: () => '/tmp/repo',
+      worktree: () => '/tmp/repo',
+      assertInfo(info: ReturnType<typeof buildPathFromBytes>) {
+        expect(info.displayPath).toBe(
+          '/tmp/repo\\evil\\x80 [bytes:base64:L3RtcC9yZXBvXGV2aWyA]',
+        );
+      },
+    },
+  ])('buildPathFromBytes $name', ({ raw, cwd, worktree, assertInfo }) => {
+    const cwdValue = cwd();
+    assertInfo(buildPathFromBytes(raw, cwdValue, worktree(cwdValue)));
+  });
+
+  test('buildPathFromBytes handles Windows drive absolute non-UTF8 paths conservatively', () => {
+    const raw = Uint8Array.from([
+      0x43, 0x3a, 0x5c, 0x72, 0x65, 0x70, 0x6f, 0x5c, 0x62, 0x61, 0x64, 0x5f,
+      0x80, 0x2e, 0x74, 0x78, 0x74,
+    ]);
+
+    const info = buildPathFromBytes(raw, 'C:\\cwd', 'C:\\repo');
+
+    expect(info.displayPath).toBe(
+      'bad_\\x80.txt [bytes:base64:QzpccmVwb1xiYWRfgC50eHQ=]',
+    );
+    expect(info.absolutePath).toBe('bytes:base64:QzpccmVwb1xiYWRfgC50eHQ=');
+    expect(info.replayPath).toBeUndefined();
+  });
+
+  test('buildPathFromBytes keeps UNC-like non-UTF8 paths absolute at root', () => {
+    const raw = Uint8Array.from([
+      0x5c, 0x5c, 0x73, 0x72, 0x76, 0x5c, 0x73, 0x68, 0x61, 0x72, 0x65, 0x5c,
+      0x62, 0x61, 0x64, 0x80,
+    ]);
+
+    const info = buildPathFromBytes(raw, 'C:\\cwd', '\\\\srv\\share\\');
+
+    expect(info.displayPath).toBe(
+      '\\\\srv\\share\\bad\\x80 [bytes:base64:XFxzcnZcc2hhcmVcYmFkgA==]',
+    );
+    expect(info.absolutePath).toBe('bytes:base64:XFxzcnZcc2hhcmVcYmFkgA==');
+  });
+
+  test('buildPathFromBytes keeps stable identity with trailing separators', () => {
+    const raw = Uint8Array.from([0x73, 0x72, 0x63, 0x2f, 0x66, 0x6f, 0x80]);
+
+    const posixA = buildPathFromBytes(raw, '/tmp/repo', '/tmp/repo');
+    const posixB = buildPathFromBytes(raw, '/tmp/repo/', '/tmp/repo/');
+    expect(posixA.absolutePath).toBe(posixB.absolutePath);
+    expect(posixA.pathKey).toBe(posixB.pathKey);
+    expect(posixA.displayPath).toBe(posixB.displayPath);
+
+    const winRaw = Uint8Array.from([0x73, 0x72, 0x63, 0x5c, 0x66, 0x6f, 0x80]);
+    const winA = buildPathFromBytes(winRaw, 'C:\\repo', 'C:\\repo');
+    const winB = buildPathFromBytes(winRaw, 'C:\\repo\\', 'C:\\repo\\');
+    expect(winA.absolutePath).toBe(winB.absolutePath);
+    expect(winA.pathKey).toBe(winB.pathKey);
+    expect(winA.displayPath).toBe(winB.displayPath);
+  });
+
+  test('buildPathFromBytes keeps canonical identity for root cwd/worktree forms', () => {
+    const raw = Uint8Array.from([0x73, 0x72, 0x63, 0x2f, 0x66, 0x6f, 0x80]);
+
+    const posix = buildPathFromBytes(raw, '/', '/');
+    expect(posix.absolutePath).toBe('bytes:base64:L3NyYy9mb4A=');
+    expect(posix.displayPath).toBe('/src/fo\\x80 [bytes:base64:L3NyYy9mb4A=]');
+
+    const winRaw = Uint8Array.from([0x73, 0x72, 0x63, 0x5c, 0x66, 0x6f, 0x80]);
+    const drive = buildPathFromBytes(winRaw, 'C:\\', 'C:\\');
+    expect(drive.absolutePath).toBe('bytes:base64:Qzpcc3JjXGZvgA==');
+    expect(drive.displayPath).toBe(
+      'C:\\src\\fo\\x80 [bytes:base64:Qzpcc3JjXGZvgA==]',
+    );
+
+    const unc = buildPathFromBytes(
+      winRaw,
+      '\\\\srv\\share\\',
+      '\\\\srv\\share\\',
+    );
+    expect(unc.absolutePath).toBe('bytes:base64:XFxzcnZcc2hhcmVcc3JjXGZvgA==');
+    expect(unc.displayPath).toBe(
+      '\\\\srv\\share\\src\\fo\\x80 [bytes:base64:XFxzcnZcc2hhcmVcc3JjXGZvgA==]',
+    );
+  });
+
+  test('distinguishes non-UTF8 payloads from literal backslash escapes', () => {
+    const literal = decodeRgPayload({ text: 'bad\\x80.txt' });
+    const nonUtf8 = decodeRgPayload({
+      bytes: Buffer.from([
+        0x62, 0x61, 0x64, 0x80, 0x2e, 0x74, 0x78, 0x74,
+      ]).toString('base64'),
+    });
+
+    expect(literal).toBe('bad\\x80.txt');
+    expect(nonUtf8).toContain('bad\\x80.txt');
+    expect(nonUtf8).toContain('[bytes:base64:');
+    expect(nonUtf8).not.toBe(literal);
+  });
+
+  test('decodeRgPayload preserves non-UTF8 bytes losslessly', () => {
+    const decoded = decodeRgPayload({
+      bytes: Buffer.from([0x66, 0x6f, 0x80, 0x0a, 0x09]).toString('base64'),
+    });
+
+    expect(decoded).toBe('fo\\x80\n\t [bytes:base64:Zm+ACgk=]');
+  });
+
+  test('sanitizeTitle escapes control characters without mangling normal backslashes', () => {
+    expect(sanitizeTitle('foo\u001b[31mbar')).toContain('\\x1b[31m');
+    expect(sanitizeTitle('C:\\repo\\file.ts')).toBe('C:\\repo\\file.ts');
+  });
+});

+ 237 - 0
src/tools/grep/resolver.test.ts

@@ -0,0 +1,237 @@
+/// <reference types="bun-types" />
+import { describe, expect, mock, test } from 'bun:test';
+import { resolveGrepCli, resolveGrepCliWithAutoInstall } from './resolver';
+import { createTempTracker } from './test-helpers';
+
+describe('tools/grep/resolver', () => {
+  createTempTracker({ resetResolver: true });
+
+  test.each([
+    {
+      name: 'prioritizes system rg over managed rg and system grep',
+      deps: {
+        findExecutable: (name: string) => {
+          if (name === 'rg') {
+            return '/usr/bin/rg';
+          }
+
+          if (name === 'grep') {
+            return '/usr/bin/grep';
+          }
+
+          return null;
+        },
+        getInstalledRipgrepPath: () =>
+          '/home/user/.cache/oh-my-opencode-slim/grep/bin/rg',
+      },
+      expected: {
+        path: '/usr/bin/rg',
+        backend: 'rg',
+        source: 'system-rg',
+      },
+    },
+    {
+      name: 'prefers managed rg before system grep',
+      deps: {
+        findExecutable: (name: string) =>
+          name === 'grep' ? '/usr/bin/grep' : null,
+        getInstalledRipgrepPath: () =>
+          '/home/user/.cache/oh-my-opencode-slim/grep/bin/rg',
+      },
+      expected: {
+        path: '/home/user/.cache/oh-my-opencode-slim/grep/bin/rg',
+        backend: 'rg',
+        source: 'managed-rg',
+      },
+    },
+    {
+      name: 'ignores non-GNU grep fallbacks',
+      deps: {
+        findExecutable: (name: string) =>
+          name === 'grep' ? '/usr/bin/grep' : null,
+        getInstalledRipgrepPath: () => null,
+        isSupportedGrep: () => false,
+      },
+      expected: {
+        path: 'rg',
+        backend: 'rg',
+        source: 'missing-rg',
+      },
+    },
+  ])('resolveGrepCli $name', ({ deps, expected }) => {
+    expect(resolveGrepCli(deps)).toEqual(expected);
+  });
+
+  test('resolveGrepCliWithAutoInstall installs ripgrep once on miss', async () => {
+    let installedPath: string | null = null;
+    const installLatest = mock(async () => {
+      installedPath = '/home/user/.cache/oh-my-opencode-slim/grep/bin/rg';
+      return installedPath;
+    });
+
+    const resolverDeps = {
+      findExecutable: (name: string) =>
+        name === 'grep' ? '/usr/bin/grep' : null,
+      getInstalledRipgrepPath: () => installedPath,
+      installLatestStableRipgrep: installLatest,
+      logger: () => undefined,
+    };
+
+    const first = await resolveGrepCliWithAutoInstall(resolverDeps);
+    const second = await resolveGrepCliWithAutoInstall(resolverDeps);
+
+    expect(first).toEqual({
+      path: '/home/user/.cache/oh-my-opencode-slim/grep/bin/rg',
+      backend: 'rg',
+      source: 'managed-rg',
+    });
+    expect(second).toEqual(first);
+    expect(installLatest.mock.calls).toHaveLength(1);
+  });
+
+  test('resolveGrepCliWithAutoInstall falls back to system grep when install fails', async () => {
+    const logger = mock(() => undefined);
+
+    const cli = await resolveGrepCliWithAutoInstall({
+      findExecutable: (name) => (name === 'grep' ? '/usr/bin/grep' : null),
+      getInstalledRipgrepPath: () => null,
+      installLatestStableRipgrep: async () => {
+        throw new Error('network down');
+      },
+      logger,
+    });
+
+    expect(cli).toEqual({
+      path: '/usr/bin/grep',
+      backend: 'grep',
+      source: 'system-gnu-grep',
+    });
+    expect(logger.mock.calls).toHaveLength(1);
+  });
+
+  test('resolveGrepCliWithAutoInstall does not cache aborts as permanent install failures', async () => {
+    const controller = new AbortController();
+    controller.abort();
+
+    await expect(
+      resolveGrepCliWithAutoInstall(
+        {
+          findExecutable: () => null,
+          getInstalledRipgrepPath: () => null,
+          installLatestStableRipgrep: async () => {
+            throw new Error('should not reach installer when already aborted');
+          },
+          logger: () => undefined,
+        },
+        controller.signal,
+      ),
+    ).rejects.toThrow(/cancelled before execution started/i);
+
+    const cli = await resolveGrepCliWithAutoInstall({
+      findExecutable: () => null,
+      getInstalledRipgrepPath: () => null,
+      installLatestStableRipgrep: async () => '/tmp/managed-rg',
+      logger: () => undefined,
+    });
+
+    expect(cli).toEqual({
+      path: '/tmp/managed-rg',
+      backend: 'rg',
+      source: 'managed-rg',
+    });
+  });
+
+  test('resolveGrepCliWithAutoInstall retries after an aborted install attempt', async () => {
+    let attempts = 0;
+    const controller = new AbortController();
+
+    const firstAttempt = resolveGrepCliWithAutoInstall(
+      {
+        findExecutable: () => null,
+        getInstalledRipgrepPath: () => null,
+        installLatestStableRipgrep: async (signal?: AbortSignal) => {
+          attempts += 1;
+          await new Promise<never>((_, reject) => {
+            signal?.addEventListener(
+              'abort',
+              () => {
+                const error = new Error('aborted');
+                error.name = 'AbortError';
+                reject(error);
+              },
+              { once: true },
+            );
+          });
+          return '/tmp/unreachable';
+        },
+        logger: () => undefined,
+      },
+      controller.signal,
+    );
+
+    controller.abort();
+
+    await expect(firstAttempt).rejects.toThrow(
+      /cancelled before execution started/i,
+    );
+
+    const secondAttempt = await resolveGrepCliWithAutoInstall({
+      findExecutable: () => null,
+      getInstalledRipgrepPath: () => null,
+      installLatestStableRipgrep: async () => {
+        attempts += 1;
+        return '/tmp/managed-rg';
+      },
+      logger: () => undefined,
+    });
+
+    expect(attempts).toBe(2);
+    expect(secondAttempt.source).toBe('managed-rg');
+  });
+
+  test('resolveGrepCliWithAutoInstall throws a clear error when rg and grep are unavailable', async () => {
+    await expect(
+      resolveGrepCliWithAutoInstall({
+        findExecutable: () => null,
+        getInstalledRipgrepPath: () => null,
+        installLatestStableRipgrep: async () => {
+          throw new Error('network down');
+        },
+        logger: () => undefined,
+      }),
+    ).rejects.toThrow(/Neither ripgrep \(rg\) nor GNU grep is available\./);
+  });
+
+  test('resolveGrepCliWithAutoInstall retries after a previous install failure when no fallback exists', async () => {
+    let attempts = 0;
+
+    await expect(
+      resolveGrepCliWithAutoInstall({
+        findExecutable: () => null,
+        getInstalledRipgrepPath: () => null,
+        installLatestStableRipgrep: async () => {
+          attempts += 1;
+          throw new Error(`network down ${attempts}`);
+        },
+        logger: () => undefined,
+      }),
+    ).rejects.toThrow(/network down 1/);
+
+    const second = await resolveGrepCliWithAutoInstall({
+      findExecutable: () => null,
+      getInstalledRipgrepPath: () => null,
+      installLatestStableRipgrep: async () => {
+        attempts += 1;
+        return '/tmp/managed-rg';
+      },
+      logger: () => undefined,
+    });
+
+    expect(attempts).toBe(2);
+    expect(second).toEqual({
+      path: '/tmp/managed-rg',
+      backend: 'rg',
+      source: 'managed-rg',
+    });
+  });
+});

+ 215 - 0
src/tools/grep/resolver.ts

@@ -0,0 +1,215 @@
+import { spawnSync } from 'node:child_process';
+import { sync as whichSync } from 'which';
+import { log } from '../../utils';
+import { GREP_BINARY, RG_BINARY } from './constants';
+import {
+  getInstalledRipgrepPath,
+  installLatestStableRipgrep,
+} from './downloader';
+import { AbortWaitError } from './runtime';
+import type { GrepBackend } from './types';
+
+export interface ResolvedGrepCli {
+  path: string;
+  backend: GrepBackend;
+  source: 'system-rg' | 'managed-rg' | 'system-gnu-grep' | 'missing-rg';
+}
+
+interface GrepResolverDependencies {
+  findExecutable?: (name: string) => string | null;
+  getInstalledRipgrepPath?: () => string | null;
+  installLatestStableRipgrep?: (signal?: AbortSignal) => Promise<string>;
+  isSupportedGrep?: (path: string) => boolean;
+  logger?: (message: string, data?: unknown) => void;
+}
+
+let autoInstallPromise: Promise<ResolvedGrepCli> | null = null;
+
+function defaultFindExecutable(name: string): string | null {
+  try {
+    const resolved = whichSync(name, { nothrow: true });
+    return Array.isArray(resolved) ? (resolved[0] ?? null) : (resolved ?? null);
+  } catch {
+    return null;
+  }
+}
+
+function buildUnavailableBackendMessage(error?: unknown): string {
+  const suffix =
+    error instanceof Error && error.message.length > 0
+      ? ` Auto-install error: ${error.message}`
+      : '';
+
+  return `Neither ripgrep (rg) nor GNU grep is available. Checked system rg, managed rg, ripgrep auto-install, and system grep.${suffix}`;
+}
+
+function isAbortLikeError(error: unknown): boolean {
+  return (
+    error instanceof AbortWaitError ||
+    (error instanceof Error && error.name === 'AbortError')
+  );
+}
+
+function defaultIsSupportedGrep(binaryPath: string): boolean {
+  try {
+    const result = spawnSync(binaryPath, ['--version'], {
+      stdio: ['ignore', 'pipe', 'pipe'],
+    });
+
+    if (result.status !== 0) {
+      return false;
+    }
+
+    const firstLine = result.stdout?.toString().split(/\r?\n/, 1)[0] ?? '';
+    return firstLine.includes('GNU grep');
+  } catch {
+    return false;
+  }
+}
+
+function resolveSync(deps: GrepResolverDependencies = {}): ResolvedGrepCli {
+  const findExecutable = deps.findExecutable ?? defaultFindExecutable;
+  const getManagedRipgrepPath =
+    deps.getInstalledRipgrepPath ?? getInstalledRipgrepPath;
+  const isSupportedGrep = deps.isSupportedGrep ?? defaultIsSupportedGrep;
+
+  const systemRg = findExecutable(RG_BINARY);
+  if (systemRg) {
+    return {
+      path: systemRg,
+      backend: 'rg',
+      source: 'system-rg',
+    };
+  }
+
+  const managedRg = getManagedRipgrepPath();
+  if (managedRg) {
+    return {
+      path: managedRg,
+      backend: 'rg',
+      source: 'managed-rg',
+    };
+  }
+
+  const systemGrep = findExecutable(GREP_BINARY);
+  if (systemGrep && isSupportedGrep(systemGrep)) {
+    return {
+      path: systemGrep,
+      backend: 'grep',
+      source: 'system-gnu-grep',
+    };
+  }
+
+  return {
+    path: RG_BINARY,
+    backend: 'rg',
+    source: 'missing-rg',
+  };
+}
+
+export function resolveGrepCli(
+  deps: GrepResolverDependencies = {},
+): ResolvedGrepCli {
+  return resolveSync(deps);
+}
+
+function isResolvedRipgrep(cli: ResolvedGrepCli): boolean {
+  return cli.backend === 'rg' && cli.source !== 'missing-rg';
+}
+
+function raceWithAbort<T>(
+  promise: Promise<T>,
+  signal?: AbortSignal,
+): Promise<T> {
+  if (!signal) {
+    return promise;
+  }
+
+  if (signal.aborted) {
+    return Promise.reject(
+      new AbortWaitError('Search was cancelled before execution started.'),
+    );
+  }
+
+  return new Promise<T>((resolve, reject) => {
+    const onAbort = () => {
+      reject(
+        new AbortWaitError('Search was cancelled before execution started.'),
+      );
+    };
+
+    signal.addEventListener('abort', onAbort, { once: true });
+    promise.then(
+      (value) => {
+        signal.removeEventListener('abort', onAbort);
+        resolve(value);
+      },
+      (error) => {
+        signal.removeEventListener('abort', onAbort);
+        reject(error);
+      },
+    );
+  });
+}
+
+export async function resolveGrepCliWithAutoInstall(
+  deps: GrepResolverDependencies = {},
+  signal?: AbortSignal,
+): Promise<ResolvedGrepCli> {
+  if (signal?.aborted) {
+    throw new AbortWaitError('Search was cancelled before execution started.');
+  }
+
+  const current = resolveSync(deps);
+  if (isResolvedRipgrep(current)) {
+    return current;
+  }
+
+  if (autoInstallPromise) {
+    return raceWithAbort(autoInstallPromise, signal);
+  }
+
+  autoInstallPromise = (async () => {
+    const installManagedRipgrep =
+      deps.installLatestStableRipgrep ?? installLatestStableRipgrep;
+
+    try {
+      const installedPath = await installManagedRipgrep(signal);
+      return {
+        path: installedPath,
+        backend: 'rg' as const,
+        source: 'managed-rg' as const,
+      };
+    } catch (error) {
+      if (isAbortLikeError(error) || signal?.aborted) {
+        throw new AbortWaitError(
+          'Search was cancelled before execution started.',
+        );
+      }
+
+      const fallback = resolveSync(deps);
+      const logger = deps.logger ?? log;
+
+      if (fallback.backend === 'grep') {
+        logger('ripgrep auto-install failed; falling back to GNU grep.', {
+          error: error instanceof Error ? error.message : String(error),
+          grep_path: fallback.path,
+        });
+        return fallback;
+      }
+
+      logger('ripgrep auto-install failed and no GNU grep fallback exists.', {
+        error: error instanceof Error ? error.message : String(error),
+      });
+      throw new Error(buildUnavailableBackendMessage(error));
+    } finally {
+      autoInstallPromise = null;
+    }
+  })();
+
+  return raceWithAbort(autoInstallPromise, signal);
+}
+
+export function resetGrepCliResolverForTests(): void {
+  autoInstallPromise = null;
+}

+ 170 - 0
src/tools/grep/result-utils.ts

@@ -0,0 +1,170 @@
+import type {
+  GrepFileMatch,
+  GrepMatchKind,
+  GrepSearchResult,
+  NormalizedGrepInput,
+} from './types';
+
+export function getMatchKind(
+  outputMode: NormalizedGrepInput['outputMode'],
+): GrepMatchKind {
+  if (outputMode === 'files_with_matches') {
+    return 'file';
+  }
+
+  if (outputMode === 'count') {
+    return 'occurrence';
+  }
+
+  return 'match';
+}
+
+export function createEmptyResult(
+  input: NormalizedGrepInput,
+  command?: string[],
+): GrepSearchResult {
+  return {
+    files: [],
+    totalMatches: 0,
+    totalFiles: 0,
+    outputMode: input.outputMode,
+    matchKind: getMatchKind(input.outputMode),
+    truncated: false,
+    limitReached: false,
+    timedOut: false,
+    cancelled: false,
+    exitCode: 0,
+    retryCount: 0,
+    command,
+    cwd: input.cwd,
+    stderr: '',
+    warnings: [],
+  };
+}
+
+export function hasVisibleResults(
+  result: Pick<GrepSearchResult, 'totalFiles' | 'totalMatches'>,
+): boolean {
+  return result.totalFiles > 0 || result.totalMatches > 0;
+}
+
+export function applySuccessfulStderr(
+  result: GrepSearchResult,
+  stderr: string,
+  exitCode: number,
+): void {
+  if (stderr.length > 0 && exitCode === 0) {
+    result.warnings.push(stderr);
+    result.stderr = '';
+  }
+}
+
+export function finalizeNonFatalExit(
+  result: GrepSearchResult,
+  exitCode: number,
+): GrepSearchResult | undefined {
+  if (exitCode === 0) {
+    return result;
+  }
+
+  if (exitCode === 1 && !hasVisibleResults(result)) {
+    return result;
+  }
+
+  if (hasVisibleResults(result)) {
+    const detail = result.stderr || `rg exited with code ${String(exitCode)}`;
+    result.truncated = true;
+    result.warnings.push(`Partial ripgrep failure: ${detail}`);
+    result.stderr = '';
+    return result;
+  }
+
+  return undefined;
+}
+
+export function countVisibleMatches(files: GrepFileMatch[]): number {
+  return files.reduce((sum, file) => sum + file.matches.length, 0);
+}
+
+export function countOccurrences(files: GrepFileMatch[]): number {
+  return files.reduce((sum, file) => sum + file.matchCount, 0);
+}
+
+export function trimFilesToLineLimit(
+  files: GrepFileMatch[],
+  maxResults: number,
+): GrepFileMatch[] {
+  let remaining = maxResults;
+  const trimmed: GrepFileMatch[] = [];
+
+  for (const file of files) {
+    if (remaining <= 0) {
+      break;
+    }
+
+    if (file.matches.length <= remaining) {
+      trimmed.push(file);
+      remaining -= file.matches.length;
+      continue;
+    }
+
+    trimmed.push({
+      ...file,
+      matchCount: Math.min(file.matchCount, remaining),
+      matches: file.matches.slice(0, remaining),
+    });
+    remaining = 0;
+  }
+
+  return trimmed;
+}
+
+export function finalizeMtimeContentResult(
+  input: NormalizedGrepInput,
+  files: GrepFileMatch[],
+  baseResult: GrepSearchResult,
+  moreDueToLimit: boolean,
+): GrepSearchResult {
+  const limitedFiles = trimFilesToLineLimit(files, input.maxResults);
+  const visibleMatches = countVisibleMatches(limitedFiles);
+  const hiddenByTrim = files.some((file, index) => {
+    const visible = limitedFiles[index];
+    return visible ? visible.matches.length < file.matches.length : true;
+  });
+  const limitReached =
+    baseResult.limitReached || moreDueToLimit || hiddenByTrim;
+
+  return {
+    ...baseResult,
+    files: limitedFiles,
+    totalMatches: visibleMatches,
+    totalFiles: limitedFiles.length,
+    matchKind: 'match',
+    truncated: baseResult.truncated || limitReached,
+    limitReached,
+  };
+}
+
+export function finalizeMtimeSimpleResult(
+  input: NormalizedGrepInput,
+  files: GrepFileMatch[],
+  baseResult: GrepSearchResult,
+  moreDueToLimit: boolean,
+): GrepSearchResult {
+  const limitedFiles = files.slice(0, input.maxResults);
+  const totalMatches =
+    input.outputMode === 'count'
+      ? countOccurrences(limitedFiles)
+      : limitedFiles.length;
+  const limitReached = baseResult.limitReached || moreDueToLimit;
+
+  return {
+    ...baseResult,
+    files: limitedFiles,
+    totalMatches,
+    totalFiles: limitedFiles.length,
+    matchKind: getMatchKind(input.outputMode),
+    truncated: baseResult.truncated || limitReached,
+    limitReached,
+  };
+}

+ 141 - 0
src/tools/grep/rg-args.ts

@@ -0,0 +1,141 @@
+import { RG_BINARY } from './constants';
+import type { NormalizedGrepInput } from './types';
+
+export function appendContextArgs(
+  args: string[],
+  input: Pick<
+    NormalizedGrepInput,
+    'afterContext' | 'beforeContext' | 'outputMode'
+  >,
+): void {
+  if (input.outputMode !== 'content') {
+    return;
+  }
+
+  if (input.beforeContext <= 0 && input.afterContext <= 0) {
+    return;
+  }
+
+  if (input.beforeContext === input.afterContext) {
+    args.push('-C', String(input.beforeContext));
+    return;
+  }
+
+  if (input.beforeContext > 0) {
+    args.push('-B', String(input.beforeContext));
+  }
+
+  if (input.afterContext > 0) {
+    args.push('-A', String(input.afterContext));
+  }
+}
+
+function appendFileTypeArgs(
+  args: string[],
+  input: Pick<NormalizedGrepInput, 'excludeFileTypes' | 'fileTypes'>,
+): void {
+  for (const fileType of input.fileTypes) {
+    args.push('--type', fileType);
+  }
+
+  for (const fileType of input.excludeFileTypes) {
+    args.push('--type-not', fileType);
+  }
+}
+
+export function buildRgArgs(input: NormalizedGrepInput): string[] {
+  const args = ['--no-config', '--color', 'never'];
+
+  if (input.outputMode === 'files_with_matches') {
+    args.push('--null', '--files-with-matches', '--with-filename');
+  }
+
+  if (input.outputMode === 'content') {
+    args.push('--json', '--with-filename', '--line-number', '--stats');
+  }
+
+  if (input.outputMode === 'count') {
+    args.push('--null', '--count-matches', '--with-filename');
+  }
+
+  if (input.sortBy === 'path') {
+    args.push(input.sortOrder === 'desc' ? '--sortr' : '--sort', 'path');
+  }
+
+  if (input.smartCase) {
+    args.push('--smart-case');
+  } else if (!input.caseSensitive) {
+    args.push('-i');
+  }
+
+  if (input.wordRegexp) {
+    args.push('-w');
+  }
+
+  appendContextArgs(args, input);
+
+  if (input.maxCountPerFile) {
+    args.push('--max-count', String(input.maxCountPerFile));
+  }
+
+  if (input.fixedStrings) {
+    args.push('--fixed-strings');
+  }
+
+  if (input.invertMatch) {
+    args.push('--invert-match');
+  }
+
+  if (input.multiline) {
+    args.push('--multiline');
+  }
+
+  if (input.multilineDotall) {
+    args.push('--multiline-dotall');
+  }
+
+  if (input.pcre2) {
+    args.push('--pcre2');
+  }
+
+  appendFileTypeArgs(args, input);
+
+  if (input.maxFilesize) {
+    args.push('--max-filesize', input.maxFilesize);
+  }
+
+  if (input.include) {
+    args.push('--glob', input.include);
+  }
+
+  for (const glob of input.globs) {
+    args.push('--glob', glob);
+  }
+
+  for (const glob of input.excludeGlobs) {
+    const normalizedGlob = glob.startsWith('!') ? glob : `!${glob}`;
+    args.push('--glob', normalizedGlob);
+  }
+
+  if (input.hidden) {
+    args.push('--hidden');
+  }
+
+  if (input.followSymlinks) {
+    args.push('--follow');
+  }
+
+  args.push(
+    '--regexp',
+    input.pattern,
+    ...(input.searchTargets ?? [input.searchPath]),
+  );
+  return args;
+}
+
+export function buildRgCommand(
+  input: NormalizedGrepInput,
+  binaryPath = RG_BINARY,
+): string[] {
+  return [binaryPath, ...buildRgArgs(input)];
+}

+ 317 - 0
src/tools/grep/runner.test.ts

@@ -0,0 +1,317 @@
+/// <reference types="bun-types" />
+import { describe, expect, test } from 'bun:test';
+import { utimesSync, writeFileSync } from 'node:fs';
+import path from 'node:path';
+import { executeFilesMode } from './direct';
+import { normalizeGrepInput } from './normalize';
+import { runRipgrep } from './runner';
+import { createGlobalAbortState, setAbortKind } from './runtime';
+import { createRepoContext, createTempTracker } from './test-helpers';
+import type { GrepToolInput } from './types';
+
+describe('tools/grep/runner', () => {
+  const temps = createTempTracker();
+
+  function createNormalized(
+    input: GrepToolInput,
+    repoDir = temps.createRepo(),
+  ) {
+    return {
+      repoDir,
+      normalized: normalizeGrepInput(input, createRepoContext(repoDir) as any),
+    };
+  }
+
+  test('runRipgrep parses NUL-delimited filenames in files/count modes', async () => {
+    const repoDir = temps.createRepo();
+    const weirdName = path.join(repoDir, 'src', 'odd\nname.ts');
+    writeFileSync(weirdName, 'const createTool = true;\ncreateTool\n');
+
+    const { normalized: filesInput } = createNormalized(
+      {
+        pattern: 'createTool',
+        path: 'src',
+        output_mode: 'files_with_matches',
+      },
+      repoDir,
+    );
+    const { normalized: countInput } = createNormalized(
+      {
+        pattern: 'createTool',
+        path: 'src',
+        output_mode: 'count',
+      },
+      repoDir,
+    );
+
+    const filesResult = await runRipgrep(
+      filesInput,
+      new AbortController().signal,
+    );
+    const countResult = await runRipgrep(
+      countInput,
+      new AbortController().signal,
+    );
+
+    expect(
+      filesResult.files.some((file) => file.absolutePath === weirdName),
+    ).toBe(true);
+    expect(
+      countResult.files.some((file) => file.absolutePath === weirdName),
+    ).toBe(true);
+  });
+
+  test('runRipgrep respects global limit in content mtime mode on a single file', async () => {
+    const repoDir = temps.createRepo();
+    const singleFile = path.join(repoDir, 'src', 'mtime-limit.ts');
+    writeFileSync(singleFile, 'needle\nneedle\nneedle\n');
+    const now = Date.now() / 1000;
+    utimesSync(singleFile, now, now + 10);
+
+    const { normalized } = createNormalized(
+      {
+        pattern: 'needle',
+        path: singleFile,
+        output_mode: 'content',
+        sort_by: 'mtime',
+        max_results: 1,
+        fixed_strings: true,
+      },
+      repoDir,
+    );
+
+    const result = await runRipgrep(normalized, new AbortController().signal);
+
+    expect(result.totalMatches).toBe(1);
+    expect(result.limitReached).toBe(true);
+    expect(result.truncated).toBe(true);
+    expect(result.command).toBeUndefined();
+    expect(result.files[0]?.matches).toHaveLength(1);
+  });
+
+  test('runRipgrep preserves mtime order in content mode across multiple files', async () => {
+    const repoDir = temps.createRepo();
+    const older = path.join(repoDir, 'src', 'older.ts');
+    const newer = path.join(repoDir, 'src', 'newer.ts');
+    const newest = path.join(repoDir, 'src', 'newest.ts');
+    writeFileSync(older, 'needle\n');
+    writeFileSync(newer, 'needle\n');
+    writeFileSync(newest, 'needle\n');
+    const now = Date.now() / 1000;
+    utimesSync(older, now - 30, now - 30);
+    utimesSync(newer, now - 20, now - 20);
+    utimesSync(newest, now - 10, now - 10);
+
+    const { normalized } = createNormalized(
+      {
+        pattern: 'needle',
+        path: path.join(repoDir, 'src'),
+        output_mode: 'content',
+        sort_by: 'mtime',
+        sort_order: 'desc',
+        max_results: 3,
+        fixed_strings: true,
+      },
+      repoDir,
+    );
+
+    const result = await runRipgrep(normalized, new AbortController().signal);
+
+    expect(result.command).toBeUndefined();
+    expect(
+      result.files.map((file) => path.basename(file.absolutePath)),
+    ).toEqual(['newest.ts', 'newer.ts', 'older.ts']);
+  });
+
+  test.each([
+    {
+      name: 'falls back to direct mode for non-UTF8 paths in mtime content mode',
+      input: {
+        pattern: 'needle',
+        output_mode: 'content',
+        expectedMatches: 1,
+        expectedFiles: undefined,
+      },
+    },
+    {
+      name: 'falls back to direct mode for non-UTF8 paths in mtime count mode',
+      input: {
+        pattern: 'needle',
+        output_mode: 'count',
+        expectedMatches: 2,
+        expectedFiles: 1,
+      },
+    },
+    {
+      name: 'falls back to direct mode for non-UTF8 paths in mtime files mode',
+      input: {
+        pattern: 'needle',
+        output_mode: 'files_with_matches',
+        expectedMatches: 1,
+        expectedFiles: 1,
+      },
+    },
+  ])('runRipgrep $name', async ({ input }) => {
+    const repoDir = temps.createRepo();
+    const rawPath = Buffer.concat([
+      Buffer.from(path.join(repoDir, 'src')),
+      Buffer.from('/bad_'),
+      Buffer.from([0x80]),
+      Buffer.from('.txt'),
+    ]);
+    const contents =
+      input.output_mode === 'count' ? 'needle\nneedle\n' : 'needle\n';
+    writeFileSync(rawPath, contents);
+
+    const { normalized } = createNormalized(
+      {
+        pattern: input.pattern,
+        path: path.join(repoDir, 'src'),
+        output_mode: input.output_mode as GrepToolInput['output_mode'],
+        sort_by: 'mtime',
+        fixed_strings: true,
+      },
+      repoDir,
+    );
+
+    const result = await runRipgrep(normalized, new AbortController().signal);
+
+    expect(result.strategy).toBe('mtime-fallback');
+    expect(result.command).toBeDefined();
+    expect(result.discoveryCommand).toBeDefined();
+    expect(result.error).toBeUndefined();
+    expect(result.partialPhase).toBeUndefined();
+    expect(result.totalMatches).toBe(input.expectedMatches);
+    if (input.expectedFiles !== undefined) {
+      expect(result.totalFiles).toBe(input.expectedFiles);
+    }
+    expect(result.warnings.join('\n')).toContain(
+      'mtime ordering disabled: 1 non-UTF8 path is not safely orderable; returned direct search results instead.',
+    );
+  });
+
+  test.each([
+    {
+      name: 'returns cancelled immediately when signal is already aborted',
+      input: { pattern: 'createTool', path: 'src' },
+      setup(controller: AbortController) {
+        controller.abort();
+      },
+      assertResult(result: Awaited<ReturnType<typeof runRipgrep>>) {
+        expect(result.cancelled).toBe(true);
+        expect(result.truncated).toBe(true);
+        expect(result.error).toBeUndefined();
+      },
+    },
+    {
+      name: 'keeps mtime-hybrid strategy metadata on pre-aborted results',
+      input: { pattern: 'needle', path: 'src', sort_by: 'mtime' },
+      setup(controller: AbortController) {
+        controller.abort();
+      },
+      assertResult(result: Awaited<ReturnType<typeof runRipgrep>>) {
+        expect(result.strategy).toBe('mtime-hybrid');
+        expect(result.discoveryCommand).toBeDefined();
+        expect(result.command).toBeUndefined();
+        expect(result.cancelled).toBe(true);
+      },
+    },
+    {
+      name: 'treats upstream timeout pre-abort as timed out in direct mode',
+      input: { pattern: 'createTool', path: 'src' },
+      setup(controller: AbortController) {
+        setAbortKind(controller.signal, 'timeout');
+        controller.abort();
+      },
+      assertResult(result: Awaited<ReturnType<typeof runRipgrep>>) {
+        expect(result.timedOut).toBe(true);
+        expect(result.cancelled).toBe(false);
+        expect(result.truncated).toBe(true);
+      },
+    },
+    {
+      name: 'treats upstream timeout pre-abort as timed out in mtime mode',
+      input: { pattern: 'needle', path: 'src', sort_by: 'mtime' },
+      setup(controller: AbortController) {
+        setAbortKind(controller.signal, 'timeout');
+        controller.abort();
+      },
+      assertResult(result: Awaited<ReturnType<typeof runRipgrep>>) {
+        expect(result.strategy).toBe('mtime-hybrid');
+        expect(result.discoveryCommand).toBeDefined();
+        expect(result.command).toBeUndefined();
+        expect(result.timedOut).toBe(true);
+        expect(result.cancelled).toBe(false);
+        expect(result.truncated).toBe(true);
+      },
+    },
+  ])('runRipgrep $name', async ({ input, setup, assertResult }) => {
+    const { normalized } = createNormalized(input);
+    const controller = new AbortController();
+    setup(controller);
+
+    assertResult(await runRipgrep(normalized, controller.signal));
+  });
+
+  test.each([
+    {
+      name: 'createGlobalAbortState keeps first cause when cancel wins',
+      run() {
+        const controller = new AbortController();
+        const state = createGlobalAbortState(controller.signal, 50);
+        controller.abort();
+        state.timeout();
+        expect(state.getCancelled()).toBe(true);
+        expect(state.getTimedOut()).toBe(false);
+        state.cleanup();
+      },
+    },
+    {
+      name: 'createGlobalAbortState keeps first cause when timeout wins',
+      run() {
+        const controller = new AbortController();
+        const state = createGlobalAbortState(controller.signal, 50);
+        state.timeout();
+        controller.abort();
+        expect(state.getTimedOut()).toBe(true);
+        expect(state.getCancelled()).toBe(false);
+        state.cleanup();
+      },
+    },
+    {
+      name: 'createGlobalAbortState preserves upstream timeout abort cause',
+      run() {
+        const controller = new AbortController();
+        setAbortKind(controller.signal, 'timeout');
+        controller.abort();
+        const state = createGlobalAbortState(controller.signal, 50);
+        expect(state.getTimedOut()).toBe(true);
+        expect(state.getCancelled()).toBe(false);
+        state.cleanup();
+      },
+    },
+  ])('$name', ({ run }) => {
+    run();
+  });
+
+  test('executeFilesMode treats pre-aborted timeout signals as timed out', async () => {
+    const { normalized } = createNormalized({
+      pattern: 'createTool',
+      path: 'src',
+      output_mode: 'files_with_matches',
+    });
+    const controller = new AbortController();
+    setAbortKind(controller.signal, 'timeout');
+    controller.abort();
+
+    const result = await executeFilesMode(normalized, controller.signal, {
+      path: 'rg',
+      backend: 'rg',
+      source: 'system-rg',
+    });
+
+    expect(result.timedOut).toBe(true);
+    expect(result.cancelled).toBe(false);
+    expect(result.truncated).toBe(true);
+  });
+});

+ 226 - 0
src/tools/grep/runner.ts

@@ -0,0 +1,226 @@
+import { DEFAULT_GREP_RETRY_COUNT } from './constants';
+import {
+  executeContentLikeMode,
+  executeCountMode,
+  executeFilesMode,
+} from './direct';
+import { buildGrepCommand, executeGrepFallback } from './fallback';
+import { buildDiscoveryInput, executeMtimeMode } from './mtime';
+import {
+  type ResolvedGrepCli,
+  resolveGrepCli,
+  resolveGrepCliWithAutoInstall,
+} from './resolver';
+import { createEmptyResult } from './result-utils';
+import { buildRgCommand } from './rg-args';
+import {
+  AbortWaitError,
+  createGlobalAbortState,
+  getRetryBackoffMs,
+  RetryableRipgrepError,
+  RUNNER_SEMAPHORE,
+  remainingTimeout,
+  sleepWithSignal,
+  toErrorMessage,
+} from './runtime';
+import type {
+  GrepRunner,
+  GrepSearchResult,
+  NormalizedGrepInput,
+} from './types';
+
+function buildFailureMeta(
+  input: NormalizedGrepInput,
+  cli: ResolvedGrepCli,
+): Pick<GrepSearchResult, 'strategy' | 'discoveryCommand'> {
+  if (input.sortBy !== 'mtime' || cli.backend === 'grep') {
+    return { strategy: 'direct', discoveryCommand: undefined };
+  }
+
+  const discoveryInput = buildDiscoveryInput(input);
+
+  return {
+    strategy: 'mtime-hybrid',
+    discoveryCommand: buildRgCommand(discoveryInput, cli.path),
+  };
+}
+
+async function executeOnce(
+  input: NormalizedGrepInput,
+  signal: AbortSignal,
+  cli: ResolvedGrepCli,
+): Promise<GrepSearchResult> {
+  if (cli.backend === 'grep') {
+    return executeGrepFallback(input, signal, cli);
+  }
+
+  if (input.sortBy === 'mtime') {
+    return executeMtimeMode(input, signal, cli);
+  }
+
+  if (input.outputMode === 'files_with_matches') {
+    return executeFilesMode(input, signal, cli);
+  }
+
+  if (input.outputMode === 'count') {
+    return executeCountMode(input, signal, cli);
+  }
+
+  return executeContentLikeMode(input, signal, cli);
+}
+
+function buildPreviewCommand(
+  input: NormalizedGrepInput,
+  cli: ResolvedGrepCli,
+): string[] | undefined {
+  if (cli.backend === 'grep') {
+    return buildGrepCommand(input, cli.path).command;
+  }
+
+  if (input.sortBy === 'mtime') {
+    return undefined;
+  }
+
+  return buildRgCommand(input, cli.path);
+}
+
+async function resolveCliForExecution(
+  signal: AbortSignal,
+): Promise<ResolvedGrepCli> {
+  if (signal.aborted) {
+    throw new AbortWaitError('Search was cancelled before execution started.');
+  }
+
+  let abortCleanup: (() => void) | undefined;
+
+  try {
+    return await Promise.race([
+      resolveGrepCliWithAutoInstall({}, signal),
+      new Promise<never>((_, reject) => {
+        const onAbort = () => {
+          reject(
+            new AbortWaitError(
+              'Search was cancelled before execution started.',
+            ),
+          );
+        };
+        signal.addEventListener('abort', onAbort, { once: true });
+        abortCleanup = () => signal.removeEventListener('abort', onAbort);
+      }),
+    ]);
+  } finally {
+    abortCleanup?.();
+  }
+}
+
+export const runRipgrep: GrepRunner = async (input, signal) => {
+  const previewCli = resolveGrepCli();
+  const command = buildPreviewCommand(input, previewCli);
+  const deadline = Date.now() + input.timeoutMs;
+  const globalAbort = createGlobalAbortState(signal, input.timeoutMs);
+
+  const createAbortedResult = (
+    attempt: number,
+    error?: string,
+  ): GrepSearchResult => ({
+    ...createEmptyResult(input, command),
+    ...buildFailureMeta(input, previewCli),
+    truncated: globalAbort.getTimedOut() || globalAbort.getCancelled(),
+    timedOut: globalAbort.getTimedOut(),
+    cancelled: globalAbort.getCancelled(),
+    retryCount: attempt,
+    ...(error ? { error } : {}),
+  });
+
+  try {
+    return await RUNNER_SEMAPHORE.use(async () => {
+      let attempt = 0;
+      let cli: ResolvedGrepCli;
+
+      try {
+        cli = await resolveCliForExecution(globalAbort.signal);
+      } catch (error) {
+        if (error instanceof AbortWaitError || globalAbort.signal.aborted) {
+          return createAbortedResult(attempt);
+        }
+
+        return {
+          ...createAbortedResult(attempt),
+          error: toErrorMessage(error),
+        };
+      }
+
+      if (cli.backend === 'grep') {
+        const grepInput: NormalizedGrepInput = {
+          ...input,
+          timeoutMs: Math.max(1, remainingTimeout(deadline)),
+        };
+        const result = await executeOnce(grepInput, globalAbort.signal, cli);
+        result.retryCount = attempt;
+        return result;
+      }
+
+      while (true) {
+        const remaining = remainingTimeout(deadline);
+        if (globalAbort.signal.aborted || remaining <= 1) {
+          if (!globalAbort.signal.aborted && remaining <= 1) {
+            globalAbort.timeout();
+          }
+          return createAbortedResult(attempt);
+        }
+
+        const scopedInput: NormalizedGrepInput = {
+          ...input,
+          timeoutMs: remaining,
+        };
+
+        try {
+          const result = await executeOnce(
+            scopedInput,
+            globalAbort.signal,
+            cli,
+          );
+          result.retryCount = attempt;
+          return result;
+        } catch (error) {
+          if (error instanceof AbortWaitError || globalAbort.signal.aborted) {
+            return createAbortedResult(attempt);
+          }
+
+          if (
+            !(error instanceof RetryableRipgrepError) ||
+            attempt >= DEFAULT_GREP_RETRY_COUNT
+          ) {
+            return {
+              ...createAbortedResult(attempt),
+              error: toErrorMessage(error),
+            };
+          }
+
+          attempt += 1;
+
+          try {
+            await sleepWithSignal(
+              getRetryBackoffMs(deadline),
+              globalAbort.signal,
+            );
+          } catch {
+            return createAbortedResult(attempt);
+          }
+        }
+      }
+    }, globalAbort.signal);
+  } catch (error) {
+    if (error instanceof AbortWaitError || globalAbort.signal.aborted) {
+      return createAbortedResult(0);
+    }
+
+    return {
+      ...createEmptyResult(input, command),
+      ...buildFailureMeta(input, previewCli),
+      error: toErrorMessage(error),
+    };
+  } finally {
+    globalAbort.cleanup();
+  }
+};

+ 342 - 0
src/tools/grep/runtime.ts

@@ -0,0 +1,342 @@
+import type { ChildProcess } from 'node:child_process';
+import { type CrossSpawnResult, crossSpawn } from '../../utils/compat';
+import {
+  DEFAULT_GREP_MAX_CONCURRENCY,
+  DEFAULT_GREP_RETRY_DELAY_MS,
+  GREP_BINARY,
+  RG_BINARY,
+} from './constants';
+import type { GrepBackend } from './types';
+
+export class RetryableRipgrepError extends Error {}
+export class AbortWaitError extends Error {}
+
+export type GrepProcess = CrossSpawnResult & {
+  proc: ChildProcess;
+};
+
+const ABORT_KIND = new WeakMap<AbortSignal, 'timeout' | 'cancel'>();
+
+export function setAbortKind(
+  signal: AbortSignal,
+  kind: 'timeout' | 'cancel',
+): boolean {
+  if (ABORT_KIND.has(signal)) {
+    return false;
+  }
+  ABORT_KIND.set(signal, kind);
+  return true;
+}
+
+export function getAbortKind(
+  signal: AbortSignal,
+): 'timeout' | 'cancel' | undefined {
+  return ABORT_KIND.get(signal);
+}
+
+export function isTimedOutAbort(signal: AbortSignal): boolean {
+  return getAbortKind(signal) === 'timeout';
+}
+
+export function toErrorMessage(error: unknown): string {
+  return error instanceof Error ? error.message : String(error);
+}
+
+export function getErrorCode(error: unknown): string | undefined {
+  return typeof error === 'object' && error && 'code' in error
+    ? String((error as { code?: unknown }).code)
+    : undefined;
+}
+
+class Semaphore {
+  private active = 0;
+  private readonly queue: Array<() => void> = [];
+
+  constructor(private readonly limit: number) {}
+
+  async use<T>(callback: () => Promise<T>, signal?: AbortSignal): Promise<T> {
+    await this.acquire(signal);
+    try {
+      return await callback();
+    } finally {
+      this.release();
+    }
+  }
+
+  private acquire(signal?: AbortSignal): Promise<void> {
+    if (signal?.aborted) {
+      return Promise.reject(
+        new AbortWaitError('Search was cancelled before execution started.'),
+      );
+    }
+
+    if (this.active < this.limit) {
+      this.active += 1;
+      return Promise.resolve();
+    }
+
+    return new Promise((resolve, reject) => {
+      const entry = () => {
+        signal?.removeEventListener('abort', onAbort);
+        this.active += 1;
+        resolve();
+      };
+      const onAbort = () => {
+        const index = this.queue.indexOf(entry);
+        if (index >= 0) {
+          this.queue.splice(index, 1);
+        }
+        reject(
+          new AbortWaitError('Search was cancelled before execution started.'),
+        );
+      };
+
+      this.queue.push(entry);
+      signal?.addEventListener('abort', onAbort, { once: true });
+    });
+  }
+
+  private release(): void {
+    this.active = Math.max(0, this.active - 1);
+    const next = this.queue.shift();
+    next?.();
+  }
+}
+
+export const RUNNER_SEMAPHORE = new Semaphore(DEFAULT_GREP_MAX_CONCURRENCY);
+
+export function sleepWithSignal(
+  ms: number,
+  signal: AbortSignal,
+): Promise<void> {
+  if (ms <= 0) {
+    return Promise.resolve();
+  }
+
+  if (signal.aborted) {
+    return Promise.reject(
+      new AbortWaitError(
+        isTimedOutAbort(signal)
+          ? 'Search retry backoff timed out.'
+          : 'Search retry backoff was aborted.',
+      ),
+    );
+  }
+
+  return new Promise((resolve, reject) => {
+    const timer = setTimeout(() => {
+      signal.removeEventListener('abort', onAbort);
+      resolve();
+    }, ms);
+    const onAbort = () => {
+      clearTimeout(timer);
+      reject(
+        new AbortWaitError(
+          isTimedOutAbort(signal)
+            ? 'Search retry backoff timed out.'
+            : 'Search retry backoff was aborted.',
+        ),
+      );
+    };
+
+    signal.addEventListener('abort', onAbort, { once: true });
+  });
+}
+
+export function remainingTimeout(deadline: number): number {
+  return Math.max(1, deadline - Date.now());
+}
+
+export function createGlobalAbortState(
+  signal: AbortSignal,
+  timeoutMs: number,
+): {
+  signal: AbortSignal;
+  cleanup: () => void;
+  getTimedOut: () => boolean;
+  getCancelled: () => boolean;
+  timeout: () => void;
+} {
+  const controller = new AbortController();
+  let kind: 'timeout' | 'cancel' | undefined;
+
+  const settle = (next: 'timeout' | 'cancel', reason: string) => {
+    if (!setAbortKind(controller.signal, next)) {
+      return false;
+    }
+    kind = next;
+    controller.abort(reason);
+    return true;
+  };
+
+  const timeoutId = setTimeout(() => {
+    if (settle('timeout', 'grep-timeout')) {
+      signal.removeEventListener('abort', onAbort);
+    }
+  }, timeoutMs);
+
+  const onAbort = () => {
+    const next = getAbortKind(signal) === 'timeout' ? 'timeout' : 'cancel';
+    if (settle(next, next === 'timeout' ? 'grep-timeout' : 'grep-cancelled')) {
+      clearTimeout(timeoutId);
+    }
+  };
+
+  if (signal.aborted) {
+    onAbort();
+  } else {
+    signal.addEventListener('abort', onAbort, { once: true });
+  }
+
+  return {
+    signal: controller.signal,
+    cleanup: () => {
+      clearTimeout(timeoutId);
+      signal.removeEventListener('abort', onAbort);
+    },
+    getTimedOut: () => kind === 'timeout',
+    getCancelled: () => kind === 'cancel',
+    timeout: () => {
+      if (settle('timeout', 'grep-timeout')) {
+        clearTimeout(timeoutId);
+        signal.removeEventListener('abort', onAbort);
+      }
+    },
+  };
+}
+
+export function killProcess(proc: GrepProcess): void {
+  try {
+    proc.kill();
+  } catch {
+    // Process may have already exited.
+  }
+}
+
+export function spawnRipgrep(command: string[], cwd: string): GrepProcess {
+  return crossSpawn(command, {
+    cwd,
+    stdout: 'pipe',
+    stderr: 'pipe',
+  }) as GrepProcess;
+}
+
+export interface TerminationState {
+  timedOut: boolean;
+  cancelled: boolean;
+}
+
+export function attachTerminationHandlers(
+  proc: GrepProcess,
+  timeoutMs: number,
+  signal: AbortSignal,
+): {
+  state: TerminationState;
+  cleanup: () => void;
+} {
+  const state: TerminationState = { timedOut: false, cancelled: false };
+  let settled = false;
+  const settle = (kind: 'timeout' | 'cancel') => {
+    if (settled) {
+      return;
+    }
+    settled = true;
+    state.timedOut = kind === 'timeout';
+    state.cancelled = kind === 'cancel';
+    killProcess(proc);
+  };
+  const timeoutId = setTimeout(() => {
+    settle('timeout');
+  }, timeoutMs);
+
+  const abortHandler = () => {
+    settle(ABORT_KIND.get(signal) === 'timeout' ? 'timeout' : 'cancel');
+  };
+
+  if (signal.aborted) {
+    abortHandler();
+  } else {
+    signal.addEventListener('abort', abortHandler, { once: true });
+  }
+
+  return {
+    state,
+    cleanup: () => {
+      clearTimeout(timeoutId);
+      signal.removeEventListener('abort', abortHandler);
+    },
+  };
+}
+
+export function createFriendlySpawnError(
+  error: unknown,
+  cli?: {
+    backend?: GrepBackend;
+    path?: string;
+  },
+): string | undefined {
+  const code = getErrorCode(error);
+  const message = toErrorMessage(error);
+
+  if (code === 'ENOENT' || /not found|enoent/i.test(message)) {
+    if (cli?.backend === 'grep' || cli?.path === GREP_BINARY) {
+      return `${GREP_BINARY} is not available. ripgrep was unavailable or auto-install failed, and GNU grep could not be executed.`;
+    }
+
+    if (cli?.backend === 'rg' || cli?.path === RG_BINARY) {
+      return `${RG_BINARY} is not available. Install ripgrep or allow the managed ripgrep installer to run.`;
+    }
+
+    return `No usable search backend is available. Install ripgrep (${RG_BINARY}) or provide GNU grep (${GREP_BINARY}).`;
+  }
+
+  return undefined;
+}
+
+export function isTransientFailure(error: unknown): boolean {
+  const code = getErrorCode(error) ?? '';
+  const message = toErrorMessage(error);
+  const text = `${code} ${message}`.toLowerCase();
+
+  return [
+    'eagain',
+    'emfile',
+    'enfile',
+    'etxtbsy',
+    'resource temporarily unavailable',
+    'too many open files',
+    'text file busy',
+  ].some((needle) => text.includes(needle));
+}
+
+export function isTransientStderr(stderr: string): boolean {
+  const text = stderr.toLowerCase();
+  return [
+    'resource temporarily unavailable',
+    'too many open files',
+    'text file busy',
+  ].some((needle) => text.includes(needle));
+}
+
+export async function waitForExitAndStderr(
+  proc: GrepProcess,
+  stderrPromise: Promise<string>,
+): Promise<{ exitCode: number; stderr: string }> {
+  const [exitResult, stderr] = await Promise.allSettled([
+    proc.exited,
+    stderrPromise,
+  ]);
+  const exitCode = exitResult.status === 'fulfilled' ? exitResult.value : 1;
+
+  return {
+    exitCode,
+    stderr: stderr.status === 'fulfilled' ? stderr.value : '',
+  };
+}
+
+export function getRetryBackoffMs(deadline: number): number {
+  return Math.min(
+    DEFAULT_GREP_RETRY_DELAY_MS,
+    Math.max(0, remainingTimeout(deadline) - 1),
+  );
+}

+ 172 - 0
src/tools/grep/schema.ts

@@ -0,0 +1,172 @@
+import { tool } from '@opencode-ai/plugin';
+import {
+  DEFAULT_GREP_CONTEXT,
+  DEFAULT_GREP_LIMIT,
+  DEFAULT_GREP_TIMEOUT_MS,
+  MAX_GREP_CONTEXT,
+  MAX_GREP_LIMIT,
+  MAX_GREP_TIMEOUT_MS,
+} from './constants';
+
+const z = tool.schema;
+
+export const grepArgsSchema: Record<string, unknown> = {
+  pattern: z
+    .string()
+    .min(1)
+    .describe('Regex pattern to search for in file contents.'),
+  path: z
+    .string()
+    .optional()
+    .describe(
+      'File or directory to search in. Defaults to the current project directory.',
+    ),
+  include: z
+    .string()
+    .optional()
+    .describe("Optional glob to include, e.g. '*.ts' or '*.{ts,tsx}'."),
+  globs: z
+    .array(z.string())
+    .optional()
+    .describe(
+      'Additional ripgrep globs. Supports include patterns and negated patterns like !dist/**.',
+    ),
+  exclude_globs: z
+    .array(z.string())
+    .optional()
+    .describe(
+      'Extra glob exclusions, automatically passed as negated rg globs.',
+    ),
+  output_mode: z
+    .enum(['content', 'files_with_matches', 'count'])
+    .default('content')
+    .describe(
+      'Output mode: content shows matching lines, files_with_matches shows only matching file paths, count shows per-file match counts.',
+    ),
+  case_sensitive: z
+    .boolean()
+    .default(true)
+    .describe('Use case-sensitive matching (default: true).'),
+  smart_case: z
+    .boolean()
+    .default(false)
+    .describe(
+      'Enable smart-case matching: lowercase patterns become case-insensitive, uppercase stays case-sensitive.',
+    ),
+  word_regexp: z.boolean().default(false).describe('Match whole words only.'),
+  context: z
+    .number()
+    .int()
+    .min(0)
+    .max(MAX_GREP_CONTEXT)
+    .default(DEFAULT_GREP_CONTEXT)
+    .describe(
+      'Symmetric context lines around each match. Used as the fallback for before_context/after_context when those are omitted.',
+    ),
+  before_context: z
+    .number()
+    .int()
+    .min(0)
+    .max(MAX_GREP_CONTEXT)
+    .optional()
+    .describe(
+      'Optional context lines before each match. When provided, it overrides the before side of context.',
+    ),
+  after_context: z
+    .number()
+    .int()
+    .min(0)
+    .max(MAX_GREP_CONTEXT)
+    .optional()
+    .describe(
+      'Optional context lines after each match. When provided, it overrides the after side of context.',
+    ),
+  max_results: z
+    .number()
+    .int()
+    .positive()
+    .max(MAX_GREP_LIMIT)
+    .default(DEFAULT_GREP_LIMIT)
+    .describe(
+      'Maximum entries to return globally. In content mode this counts match entries; in files_with_matches/count it counts files.',
+    ),
+  max_count_per_file: z
+    .number()
+    .int()
+    .positive()
+    .optional()
+    .describe(
+      'Optional ripgrep per-file match cap. Uses rg max-count when supported by the selected output mode.',
+    ),
+  timeout_ms: z
+    .number()
+    .int()
+    .positive()
+    .max(MAX_GREP_TIMEOUT_MS)
+    .default(DEFAULT_GREP_TIMEOUT_MS)
+    .describe('Timeout in milliseconds for the rg process.'),
+  hidden: z
+    .boolean()
+    .default(true)
+    .describe(
+      'Include hidden files and directories while still respecting rg ignore rules.',
+    ),
+  follow_symlinks: z
+    .boolean()
+    .default(false)
+    .describe('Follow symbolic links.'),
+  fixed_strings: z
+    .boolean()
+    .default(false)
+    .describe('Treat the pattern as a literal string instead of a regex.'),
+  invert_match: z
+    .boolean()
+    .default(false)
+    .describe('Invert the search so ripgrep returns non-matching lines.'),
+  multiline: z
+    .boolean()
+    .default(false)
+    .describe('Enable multiline regex mode so matches can span line breaks.'),
+  multiline_dotall: z
+    .boolean()
+    .default(false)
+    .describe(
+      'When multiline is enabled, also let . match newlines (ripgrep multiline-dotall).',
+    ),
+  pcre2: z
+    .boolean()
+    .default(false)
+    .describe(
+      'Use the PCRE2 regex engine for advanced regex features when needed.',
+    ),
+  file_type: z
+    .string()
+    .optional()
+    .describe(
+      'Optional single ripgrep file type filter, e.g. ts, js, py, md. Works alongside file_types.',
+    ),
+  file_types: z
+    .array(z.string())
+    .optional()
+    .describe(
+      'Optional ripgrep file type filters to include. Applied in addition to file_type.',
+    ),
+  exclude_file_types: z
+    .array(z.string())
+    .optional()
+    .describe('Optional ripgrep file type filters to exclude using type-not.'),
+  max_filesize: z
+    .string()
+    .optional()
+    .describe('Optional ripgrep max-filesize value such as 1M, 500K, or 2G.'),
+  sort_by: z
+    .enum(['none', 'path', 'mtime'])
+    .default('none')
+    .describe(
+      'Optional result ordering. Path ordering uses ripgrep sort mode; mtime orders by file modification time.',
+    ),
+  sort_order: z
+    .enum(['asc', 'desc'])
+    .optional()
+    .describe('Ordering direction when sort_by is enabled.'),
+};

+ 31 - 0
src/tools/grep/summary.ts

@@ -0,0 +1,31 @@
+import type { GrepSearchResult, NormalizedGrepInput } from './types';
+
+export function pluralize(
+  count: number,
+  singular: string,
+  plural = singular.endsWith('match') ? `${singular}es` : `${singular}s`,
+): string {
+  return count === 1 ? singular : plural;
+}
+
+export function buildPrimarySummary(result: GrepSearchResult): string {
+  switch (result.matchKind) {
+    case 'file':
+      return `Found ${result.totalMatches} matching ${pluralize(result.totalMatches, 'file')}.`;
+    case 'occurrence':
+      return `Found ${result.totalMatches} total ${pluralize(result.totalMatches, 'match')} across ${result.totalFiles} ${pluralize(result.totalFiles, 'file')}.`;
+    default:
+      return `Found ${result.totalMatches} ${pluralize(result.totalMatches, 'match')} across ${result.totalFiles} ${pluralize(result.totalFiles, 'file')}.`;
+  }
+}
+
+export function buildLimitNote(
+  input: NormalizedGrepInput,
+  result: GrepSearchResult,
+): string {
+  if (result.matchKind === 'file' || result.outputMode === 'count') {
+    return `Stopped after collecting ${input.maxResults} matching ${pluralize(input.maxResults, 'file')} (global limit).`;
+  }
+
+  return `Stopped after collecting ${input.maxResults} ${pluralize(input.maxResults, 'match')} (global limit).`;
+}

+ 154 - 0
src/tools/grep/test-helpers.ts

@@ -0,0 +1,154 @@
+/// <reference types="bun-types" />
+import { afterEach, mock } from 'bun:test';
+import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { resetGrepCliResolverForTests } from './resolver';
+import type { GrepSearchResult } from './types';
+
+function createTempPath(prefix: string): string {
+  return path.join(
+    os.tmpdir(),
+    `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+  );
+}
+
+export function createTempDir(prefix = 'oh-my-opencode-grep'): string {
+  const dir = createTempPath(prefix);
+  mkdirSync(dir, { recursive: true });
+  return dir;
+}
+
+export function createTempRepo(): string {
+  const dir = createTempDir('oh-my-opencode-grep');
+  mkdirSync(path.join(dir, 'src'), { recursive: true });
+  writeFileSync(
+    path.join(dir, 'src', 'example.ts'),
+    [
+      "import { createTool } from './tool';",
+      'const target = createTool();',
+      'export { target };',
+      '',
+    ].join('\n'),
+  );
+  return dir;
+}
+
+export function createTempTracker(options: { resetResolver?: boolean } = {}) {
+  const tempDirs: string[] = [];
+
+  afterEach(() => {
+    for (const dir of tempDirs.splice(0)) {
+      rmSync(dir, { recursive: true, force: true });
+    }
+
+    if (options.resetResolver) {
+      resetGrepCliResolverForTests();
+    }
+
+    mock.restore();
+  });
+
+  return {
+    add<T extends string>(dir: T): T {
+      tempDirs.push(dir);
+      return dir;
+    },
+    createDir(prefix?: string): string {
+      const dir = createTempDir(prefix);
+      tempDirs.push(dir);
+      return dir;
+    },
+    createRepo(): string {
+      const dir = createTempRepo();
+      tempDirs.push(dir);
+      return dir;
+    },
+  };
+}
+
+export function createRepoContext(directory: string, worktree = directory) {
+  return {
+    directory,
+    worktree,
+  } as const;
+}
+
+export function createExecutionContext(
+  directory: string,
+  worktree = directory,
+) {
+  return {
+    ask: mock(async () => undefined),
+    metadata: mock(() => undefined),
+    abort: new AbortController().signal,
+    directory,
+    worktree,
+    sessionID: 'session-1',
+    messageID: 'message-1',
+    agent: 'explorer',
+  } as const;
+}
+
+export function createTextStream(chunks: string[]): ReadableStream<Uint8Array> {
+  const encoder = new TextEncoder();
+  return new ReadableStream({
+    start(controller) {
+      for (const chunk of chunks) {
+        controller.enqueue(encoder.encode(chunk));
+      }
+      controller.close();
+    },
+  });
+}
+
+export function buildResult(directory: string): GrepSearchResult {
+  return {
+    files: [
+      {
+        file: 'src/example.ts',
+        absolutePath: path.join(directory, 'src', 'example.ts'),
+        matchCount: 1,
+        matches: [
+          {
+            lineNumber: 2,
+            lineText: 'const target = createTool();',
+            submatches: ['createTool'],
+            before: [
+              {
+                lineNumber: 1,
+                text: "import { createTool } from './tool';",
+              },
+            ],
+            after: [
+              {
+                lineNumber: 3,
+                text: 'export { target };',
+              },
+            ],
+          },
+        ],
+      },
+    ],
+    totalMatches: 1,
+    totalFiles: 1,
+    outputMode: 'content',
+    matchKind: 'match',
+    truncated: false,
+    limitReached: false,
+    timedOut: false,
+    cancelled: false,
+    exitCode: 0,
+    retryCount: 0,
+    command: [
+      'rg',
+      '--json',
+      '--regexp',
+      'createTool',
+      path.join(directory, 'src'),
+    ],
+    cwd: directory,
+    stderr: '',
+    warnings: [],
+  };
+}

+ 353 - 0
src/tools/grep/tool.test.ts

@@ -0,0 +1,353 @@
+/// <reference types="bun-types" />
+import { describe, expect, mock, test } from 'bun:test';
+import path from 'node:path';
+import { DEFAULT_GREP_TIMEOUT_MS } from './constants';
+import {
+  buildResult,
+  createExecutionContext,
+  createTempTracker,
+} from './test-helpers';
+import { createGrepTool } from './tool';
+import type { GrepRunner, GrepSearchResult } from './types';
+
+describe('tools/grep/tool', () => {
+  const temps = createTempTracker();
+
+  function getAskInput(ctx: ReturnType<typeof createExecutionContext>) {
+    const call = ctx.ask.mock.calls[0] as unknown as
+      | [
+          {
+            permission: string;
+            patterns: string[];
+            metadata: Record<string, unknown>;
+          },
+        ]
+      | undefined;
+
+    if (!call) {
+      throw new Error('ask was not called');
+    }
+
+    return call[0];
+  }
+
+  function getMetadataInput(ctx: ReturnType<typeof createExecutionContext>) {
+    const call = ctx.metadata.mock.calls[0] as unknown as
+      | [
+          {
+            title: string;
+            metadata: Record<string, unknown>;
+          },
+        ]
+      | undefined;
+
+    if (!call) {
+      throw new Error('metadata was not called');
+    }
+
+    return call[0];
+  }
+
+  test('creates a grep tool that asks permission, runs rg, and emits metadata', async () => {
+    const repoDir = temps.createRepo();
+    const run: GrepRunner = mock(async (input) => {
+      expect(input.searchPath).toBe(path.join(repoDir, 'src'));
+      expect(input.include).toBe('*.ts');
+      return buildResult(repoDir);
+    });
+
+    const grep = createGrepTool(
+      {
+        directory: repoDir,
+        worktree: repoDir,
+        client: {},
+      } as any,
+      { run },
+    );
+    const ctx = createExecutionContext(repoDir);
+
+    const output = await grep.execute(
+      {
+        pattern: 'createTool',
+        path: 'src',
+        include: '*.ts',
+      },
+      ctx as any,
+    );
+
+    expect(output).toContain('Found 1 match across 1 file.');
+    expect(output).toContain('src/example.ts');
+    expect(output).toContain('2: const target = createTool();');
+    expect(ctx.ask).toHaveBeenCalledTimes(1);
+    expect(ctx.metadata).toHaveBeenCalledTimes(1);
+
+    const askInput = getAskInput(ctx);
+    expect(askInput.permission).toBe('grep');
+    expect(askInput.patterns).toEqual([path.join(repoDir, 'src')]);
+    expect(askInput.metadata.before_context).toBe(0);
+    expect(askInput.metadata.after_context).toBe(0);
+    expect(askInput.metadata.pattern).toBe('createTool');
+    expect(askInput.metadata.case_sensitive).toBe(true);
+    expect(askInput.metadata.word_regexp).toBe(false);
+    expect(askInput.metadata.timeout_ms).toBe(DEFAULT_GREP_TIMEOUT_MS);
+    expect(askInput.metadata.hidden).toBe(true);
+    expect(askInput.metadata.follow_symlinks).toBe(false);
+    expect(askInput.metadata.real_path_exhaustive).toBe(true);
+    expect(askInput.metadata.fixed_strings).toBe(false);
+    expect(askInput.metadata.multiline).toBe(false);
+    expect(askInput.metadata.multiline_dotall).toBe(false);
+    expect(askInput.metadata.pcre2).toBe(false);
+    expect(askInput.metadata.max_filesize).toBeUndefined();
+    expect(askInput.metadata.sort_by).toBe('none');
+    expect(askInput.metadata.sort_order).toBe('asc');
+    expect(askInput.metadata.file_types).toEqual([]);
+    expect(askInput.metadata.exclude_file_types).toEqual([]);
+    expect(askInput.metadata.invert_match).toBe(false);
+    expect(askInput.metadata.max_count_per_file).toBeUndefined();
+    expect(askInput.metadata.route_mode).toBeUndefined();
+    expect(askInput.metadata.requested_backend).toBeUndefined();
+    expect(askInput.metadata.effective_backend).toBeUndefined();
+    expect(askInput.metadata.route_fallback_reason).toBeUndefined();
+
+    const metadataInput = getMetadataInput(ctx);
+    expect(metadataInput.title).toBe('createTool');
+    expect(metadataInput.metadata.backend).toBe('rg');
+    expect(metadataInput.metadata.pattern).toBe('createTool');
+    expect(metadataInput.metadata.case_sensitive).toBe(true);
+    expect(metadataInput.metadata.word_regexp).toBe(false);
+    expect(metadataInput.metadata.timeout_ms).toBe(DEFAULT_GREP_TIMEOUT_MS);
+    expect(metadataInput.metadata.hidden).toBe(true);
+    expect(metadataInput.metadata.follow_symlinks).toBe(false);
+    expect(metadataInput.metadata.real_path_exhaustive).toBe(true);
+    expect(metadataInput.metadata.fixed_strings).toBe(false);
+    expect(metadataInput.metadata.multiline).toBe(false);
+    expect(metadataInput.metadata.multiline_dotall).toBe(false);
+    expect(metadataInput.metadata.pcre2).toBe(false);
+    expect(metadataInput.metadata.max_filesize).toBeUndefined();
+    expect(metadataInput.metadata.sort_by).toBe('none');
+    expect(metadataInput.metadata.sort_order).toBe('asc');
+    expect(metadataInput.metadata.matches).toBe(1);
+    expect(metadataInput.metadata.before_context).toBe(0);
+    expect(metadataInput.metadata.after_context).toBe(0);
+    expect(metadataInput.metadata.file_types).toEqual([]);
+    expect(metadataInput.metadata.exclude_file_types).toEqual([]);
+    expect(metadataInput.metadata.invert_match).toBe(false);
+    expect(metadataInput.metadata.max_count_per_file).toBeUndefined();
+    expect(metadataInput.metadata.route_mode).toBeUndefined();
+    expect(metadataInput.metadata.requested_backend).toBeUndefined();
+    expect(metadataInput.metadata.effective_backend).toBeUndefined();
+    expect(metadataInput.metadata.route_fallback_reason).toBeUndefined();
+  });
+
+  test('emits strategy metadata for mtime-hybrid results', async () => {
+    const repoDir = temps.createRepo();
+    const run: GrepRunner = mock(async () => {
+      return {
+        ...buildResult(repoDir),
+        outputMode: 'files_with_matches',
+        matchKind: 'file',
+        files: [
+          {
+            file: 'src/example.ts',
+            absolutePath: path.join(repoDir, 'src', 'example.ts'),
+            replayPath: path.join(repoDir, 'src', 'example.ts'),
+            matchCount: 1,
+            matches: [],
+          },
+        ],
+        totalMatches: 1,
+        totalFiles: 1,
+        strategy: 'mtime-hybrid',
+        discoveryCommand: ['rg', '--files-with-matches', 'needle', repoDir],
+        replayBatchCount: 2,
+        replayTargetCount: 5,
+        discoveredFiles: 5,
+        sortedFiles: 5,
+        replayedFiles: 1,
+        partialPhase: 'replay',
+        mtimeDiscoveryCapped: true,
+        command: undefined,
+      } satisfies GrepSearchResult;
+    });
+
+    const grep = createGrepTool(
+      {
+        directory: repoDir,
+        worktree: repoDir,
+        client: {},
+      } as any,
+      { run },
+    );
+    const ctx = createExecutionContext(repoDir);
+
+    await grep.execute(
+      {
+        pattern: 'needle',
+        path: 'src',
+        sort_by: 'mtime',
+        output_mode: 'files_with_matches',
+      },
+      ctx as any,
+    );
+
+    const metadataInput = getMetadataInput(ctx);
+    expect(metadataInput.metadata.strategy).toBe('mtime-hybrid');
+    expect(metadataInput.metadata.command).toBeUndefined();
+    expect(metadataInput.metadata.discovery_command).toEqual([
+      'rg',
+      '--files-with-matches',
+      'needle',
+      repoDir,
+    ]);
+    expect(metadataInput.metadata.replay_batch_count).toBe(2);
+    expect(metadataInput.metadata.replay_target_count).toBe(5);
+    expect(metadataInput.metadata.discovered_files).toBe(5);
+    expect(metadataInput.metadata.sorted_files).toBe(5);
+    expect(metadataInput.metadata.replayed_files).toBe(1);
+    expect(metadataInput.metadata.partial_phase).toBe('replay');
+    expect(metadataInput.metadata.mtime_discovery_capped).toBe(true);
+  });
+
+  test('emits metadata when normalization fails before execution', async () => {
+    const repoDir = temps.createRepo();
+    const grep = createGrepTool({
+      directory: repoDir,
+      worktree: repoDir,
+      client: {},
+    } as any);
+    const ctx = createExecutionContext(repoDir);
+
+    await expect(
+      grep.execute(
+        {
+          pattern: 'createTool',
+          path: 'missing-path',
+        },
+        ctx as any,
+      ),
+    ).rejects.toThrow(/Search path does not exist/);
+
+    expect(ctx.metadata).toHaveBeenCalledTimes(1);
+    const metadataInput = getMetadataInput(ctx);
+    expect(metadataInput.title).toBe('createTool');
+    expect(metadataInput.metadata.error_stage).toBe('normalize');
+    expect(metadataInput.metadata.pattern).toBe('createTool');
+    expect(metadataInput.metadata.path).toBe('missing-path');
+  });
+
+  test('does not let metadata failure break a successful grep result', async () => {
+    const repoDir = temps.createRepo();
+    const run: GrepRunner = mock(async () => buildResult(repoDir));
+    const grep = createGrepTool(
+      {
+        directory: repoDir,
+        worktree: repoDir,
+        client: {},
+      } as any,
+      { run },
+    );
+    const ctx = {
+      ...createExecutionContext(repoDir),
+      metadata: mock(() => {
+        throw new Error('metadata channel failed');
+      }),
+    };
+
+    const output = await grep.execute(
+      {
+        pattern: 'createTool',
+        path: 'src',
+      },
+      ctx as any,
+    );
+
+    expect(output).toContain('Found 1 match across 1 file.');
+  });
+
+  test('emits metadata when permission step fails', async () => {
+    const repoDir = temps.createRepo();
+    const grep = createGrepTool({
+      directory: repoDir,
+      worktree: repoDir,
+      client: {},
+    } as any);
+    const ctx = {
+      ...createExecutionContext(repoDir),
+      ask: mock(async () => {
+        throw new Error('permission denied');
+      }),
+    };
+
+    await expect(
+      grep.execute(
+        {
+          pattern: 'createTool',
+          path: 'src',
+        },
+        ctx as any,
+      ),
+    ).rejects.toThrow(/permission denied/);
+
+    expect(ctx.metadata).toHaveBeenCalledTimes(1);
+    const metadataCalls = ctx.metadata.mock.calls as unknown as Array<
+      [{ metadata: Record<string, unknown> }]
+    >;
+    const metadataInput = metadataCalls[0]?.[0];
+    expect(metadataInput?.metadata.error_stage).toBe('permission');
+    expect(metadataInput?.metadata.real_path).toBe(path.join(repoDir, 'src'));
+  });
+
+  test('preserves the original error when metadata emission also fails', async () => {
+    const repoDir = temps.createRepo();
+    const grep = createGrepTool({
+      directory: repoDir,
+      worktree: repoDir,
+      client: {},
+    } as any);
+    const ctx = {
+      ...createExecutionContext(repoDir),
+      metadata: mock(() => {
+        throw new Error('metadata channel failed');
+      }),
+    };
+
+    await expect(
+      grep.execute(
+        {
+          pattern: 'createTool',
+          path: 'missing-path',
+        },
+        ctx as any,
+      ),
+    ).rejects.toThrow(/Search path does not exist/);
+  });
+
+  test('sanitizes multiline pattern titles and reports real path metadata', async () => {
+    const repoDir = temps.createRepo();
+    const run: GrepRunner = mock(async () => buildResult(repoDir));
+    const grep = createGrepTool(
+      {
+        directory: repoDir,
+        worktree: repoDir,
+        client: {},
+      } as any,
+      { run },
+    );
+    const ctx = createExecutionContext(repoDir);
+
+    await grep.execute(
+      {
+        pattern: 'first line\nsecond line',
+        path: 'src',
+      },
+      ctx as any,
+    );
+
+    const metadataInput = getMetadataInput(ctx);
+    expect(metadataInput.title).toBe('first line second line');
+    expect(metadataInput.metadata.resolved_path).toBe(
+      path.join(repoDir, 'src'),
+    );
+    expect(metadataInput.metadata.real_path).toBe(path.join(repoDir, 'src'));
+  });
+});

+ 206 - 0
src/tools/grep/tool.ts

@@ -0,0 +1,206 @@
+import {
+  type PluginInput,
+  type ToolDefinition,
+  tool,
+} from '@opencode-ai/plugin';
+import { GREP_DESCRIPTION, GREP_TOOL_ID } from './constants';
+import { formatGrepResult } from './format';
+import { normalizeGrepInput } from './normalize';
+import { sanitizeTitle } from './path-utils';
+import { runRipgrep } from './runner';
+import { grepArgsSchema } from './schema';
+import type {
+  GrepRunner,
+  GrepSearchResult,
+  GrepToolInput,
+  NormalizedGrepInput,
+} from './types';
+
+interface CreateGrepToolOptions {
+  run?: GrepRunner;
+}
+
+function getRawPattern(args: GrepToolInput): string {
+  return typeof args.pattern === 'string' && args.pattern.length > 0
+    ? args.pattern
+    : 'grep';
+}
+
+function getTitle(
+  args: GrepToolInput,
+  normalized?: NormalizedGrepInput,
+): string {
+  return sanitizeTitle(normalized?.pattern ?? getRawPattern(args));
+}
+
+function buildBaseMetadata(
+  args: GrepToolInput,
+  normalized?: NormalizedGrepInput,
+): Record<string, unknown> {
+  return {
+    backend: 'rg',
+    pattern: normalized?.pattern ?? getRawPattern(args),
+    path: normalized?.requestedPath ?? args.path,
+    resolved_path: normalized?.resolvedPath,
+    real_path: normalized?.searchPath,
+    include: normalized?.include ?? args.include,
+    globs: normalized?.globs ?? args.globs ?? [],
+    exclude_globs: normalized?.excludeGlobs ?? args.exclude_globs ?? [],
+    output_mode: normalized?.outputMode ?? args.output_mode ?? 'content',
+    case_sensitive: normalized?.caseSensitive ?? args.case_sensitive !== false,
+    smart_case: normalized?.smartCase ?? args.smart_case === true,
+    word_regexp: normalized?.wordRegexp ?? args.word_regexp === true,
+    context: normalized?.context ?? args.context,
+    context_requested: normalized?.context ?? args.context,
+    context_effective:
+      normalized && normalized.beforeContext === normalized.afterContext
+        ? normalized.beforeContext
+        : undefined,
+    before_context: normalized?.beforeContext ?? args.before_context,
+    after_context: normalized?.afterContext ?? args.after_context,
+    max_results: normalized?.maxResults ?? args.max_results,
+    max_count_per_file: normalized?.maxCountPerFile ?? args.max_count_per_file,
+    timeout_ms: normalized?.timeoutMs ?? args.timeout_ms,
+    hidden: normalized?.hidden ?? args.hidden !== false,
+    follow_symlinks:
+      normalized?.followSymlinks ?? args.follow_symlinks === true,
+    real_path_exhaustive: normalized
+      ? !normalized.followSymlinks
+      : args.follow_symlinks !== true,
+    fixed_strings: normalized?.fixedStrings ?? args.fixed_strings === true,
+    invert_match: normalized?.invertMatch ?? args.invert_match === true,
+    multiline: normalized?.multiline ?? args.multiline === true,
+    multiline_dotall:
+      normalized?.multilineDotall ?? args.multiline_dotall === true,
+    pcre2: normalized?.pcre2 ?? args.pcre2 === true,
+    file_type: normalized?.fileType ?? args.file_type,
+    file_types: normalized?.fileTypes ?? args.file_types ?? [],
+    exclude_file_types:
+      normalized?.excludeFileTypes ?? args.exclude_file_types ?? [],
+    max_filesize: normalized?.maxFilesize ?? args.max_filesize,
+    sort_by: normalized?.sortBy ?? args.sort_by ?? 'none',
+    sort_order: normalized?.sortOrder ?? args.sort_order,
+  };
+}
+
+function buildResultMetadata(
+  args: GrepToolInput,
+  normalized: NormalizedGrepInput,
+  result: GrepSearchResult,
+): Record<string, unknown> {
+  const strategy =
+    result.strategy ??
+    (normalized.sortBy === 'mtime' ? 'mtime-hybrid' : 'direct');
+
+  return {
+    ...buildBaseMetadata(args, normalized),
+    backend: result.backend ?? 'rg',
+    matches: result.totalMatches,
+    match_kind: result.matchKind,
+    files: result.totalFiles,
+    truncated: result.truncated,
+    limit_reached: result.limitReached,
+    timed_out: result.timedOut,
+    cancelled: result.cancelled,
+    retry_count: result.retryCount,
+    exit_code: result.exitCode,
+    error: result.error,
+    cwd: result.cwd,
+    command: result.command,
+    strategy,
+    discovery_command: result.discoveryCommand,
+    replay_batch_count: result.replayBatchCount,
+    replay_target_count: result.replayTargetCount,
+    discovered_files: result.discoveredFiles,
+    sorted_files: result.sortedFiles,
+    replayed_files: result.replayedFiles,
+    partial_phase: result.partialPhase,
+    mtime_discovery_capped: result.mtimeDiscoveryCapped,
+  };
+}
+
+function buildFailureMetadata(
+  args: GrepToolInput,
+  stage: 'normalize' | 'permission' | 'execution',
+  error: unknown,
+  normalized?: NormalizedGrepInput,
+): Record<string, unknown> {
+  return {
+    ...buildBaseMetadata(args, normalized),
+    truncated: false,
+    limit_reached: false,
+    timed_out: false,
+    cancelled: false,
+    retry_count: 0,
+    exit_code: undefined,
+    error: error instanceof Error ? error.message : String(error),
+    error_stage: stage,
+  };
+}
+
+async function emitMetadataSafely(
+  ctx: {
+    metadata: (payload: {
+      title: string;
+      metadata: Record<string, unknown>;
+    }) => Promise<unknown> | unknown;
+  },
+  title: string,
+  metadata: Record<string, unknown>,
+): Promise<boolean> {
+  try {
+    await ctx.metadata({ title, metadata });
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+export function createGrepTool(
+  pluginCtx: PluginInput,
+  options: CreateGrepToolOptions = {},
+): ToolDefinition {
+  const run = options.run ?? runRipgrep;
+  const argsSchema = grepArgsSchema as Parameters<typeof tool>[0]['args'];
+
+  return tool({
+    description: GREP_DESCRIPTION,
+    args: argsSchema,
+    async execute(args, ctx) {
+      const rawArgs = args as unknown as GrepToolInput;
+      let normalized: NormalizedGrepInput | undefined;
+      let stage: 'normalize' | 'permission' | 'execution' = 'normalize';
+
+      try {
+        normalized = normalizeGrepInput(rawArgs, ctx, pluginCtx);
+        stage = 'permission';
+
+        await ctx.ask({
+          permission: GREP_TOOL_ID,
+          patterns: normalized.permissionPatterns,
+          always: normalized.permissionPatterns,
+          metadata: buildBaseMetadata(rawArgs, normalized),
+        });
+
+        stage = 'execution';
+        const result = await run(normalized, ctx.abort);
+        const output = formatGrepResult(normalized, result);
+
+        await emitMetadataSafely(
+          ctx,
+          getTitle(rawArgs, normalized),
+          buildResultMetadata(rawArgs, normalized, result),
+        );
+
+        return output;
+      } catch (error) {
+        await emitMetadataSafely(
+          ctx,
+          getTitle(rawArgs, normalized),
+          buildFailureMetadata(rawArgs, stage, error, normalized),
+        );
+        throw error;
+      }
+    },
+  });
+}

+ 207 - 0
src/tools/grep/types.ts

@@ -0,0 +1,207 @@
+export interface GrepToolInput {
+  pattern: string;
+  path?: string;
+  include?: string;
+  globs?: string[];
+  exclude_globs?: string[];
+  output_mode?: GrepOutputMode;
+  case_sensitive?: boolean;
+  smart_case?: boolean;
+  word_regexp?: boolean;
+  context?: number;
+  before_context?: number;
+  after_context?: number;
+  max_results?: number;
+  max_count_per_file?: number;
+  timeout_ms?: number;
+  hidden?: boolean;
+  follow_symlinks?: boolean;
+  fixed_strings?: boolean;
+  invert_match?: boolean;
+  multiline?: boolean;
+  multiline_dotall?: boolean;
+  pcre2?: boolean;
+  file_type?: string;
+  file_types?: string[];
+  exclude_file_types?: string[];
+  max_filesize?: string;
+  sort_by?: GrepSortMode;
+  sort_order?: GrepSortOrder;
+}
+
+export type GrepOutputMode = 'content' | 'files_with_matches' | 'count';
+export type GrepSortMode = 'none' | 'path' | 'mtime';
+export type GrepSortOrder = 'asc' | 'desc';
+export type GrepMatchKind = 'match' | 'file' | 'occurrence';
+export type GrepBackend = 'rg' | 'grep';
+
+export interface NormalizedGrepInput {
+  pattern: string;
+  requestedPath: string;
+  resolvedPath: string;
+  searchPath: string;
+  include?: string;
+  globs: string[];
+  excludeGlobs: string[];
+  outputMode: GrepOutputMode;
+  caseSensitive: boolean;
+  smartCase: boolean;
+  wordRegexp: boolean;
+  context: number;
+  beforeContext: number;
+  afterContext: number;
+  maxResults: number;
+  maxCountPerFile?: number;
+  timeoutMs: number;
+  hidden: boolean;
+  followSymlinks: boolean;
+  fixedStrings: boolean;
+  invertMatch: boolean;
+  multiline: boolean;
+  multilineDotall: boolean;
+  pcre2: boolean;
+  fileType?: string;
+  fileTypes: string[];
+  excludeFileTypes: string[];
+  maxFilesize?: string;
+  sortBy: GrepSortMode;
+  sortOrder: GrepSortOrder;
+  searchTargets?: string[];
+  cwd: string;
+  worktree: string;
+  permissionPatterns: string[];
+}
+
+export interface GrepContextLine {
+  lineNumber: number;
+  text: string;
+}
+
+export interface GrepMatch {
+  lineNumber: number;
+  lineText: string;
+  submatches: string[];
+  before: GrepContextLine[];
+  after: GrepContextLine[];
+}
+
+export interface GrepFileMatch {
+  file: string;
+  absolutePath: string;
+  replayPath?: string;
+  nonUtf8Path?: boolean;
+  pathKey?: string;
+  matchCount: number;
+  matches: GrepMatch[];
+}
+
+export interface GrepSummaryData {
+  elapsedTotalMs?: number;
+  elapsedTotalHuman?: string;
+  stats?: Record<string, unknown>;
+}
+
+export interface GrepSearchResult {
+  files: GrepFileMatch[];
+  totalMatches: number;
+  totalFiles: number;
+  backend?: GrepBackend;
+  outputMode: GrepOutputMode;
+  matchKind: GrepMatchKind;
+  truncated: boolean;
+  limitReached: boolean;
+  timedOut: boolean;
+  cancelled: boolean;
+  exitCode: number;
+  retryCount: number;
+  command?: string[];
+  cwd: string;
+  stderr: string;
+  warnings: string[];
+  error?: string;
+  summary?: GrepSummaryData;
+  strategy?: 'direct' | 'mtime-hybrid' | 'mtime-fallback';
+  discoveryCommand?: string[];
+  replayBatchCount?: number;
+  replayTargetCount?: number;
+  discoveredFiles?: number;
+  sortedFiles?: number;
+  replayedFiles?: number;
+  partialPhase?: 'discovery' | 'mtime-sort' | 'replay';
+  mtimeDiscoveryCapped?: boolean;
+}
+
+export type GrepRunner = (
+  input: NormalizedGrepInput,
+  signal: AbortSignal,
+) => Promise<GrepSearchResult>;
+
+export interface RgTextPayload {
+  text: string;
+  bytes?: string;
+}
+
+export interface RgPathPayload {
+  text?: string;
+  bytes?: string;
+}
+
+export interface RgSubmatch {
+  match: RgTextPayload;
+  start: number;
+  end: number;
+}
+
+interface RgLineData {
+  path?: RgPathPayload;
+  lines?: RgTextPayload;
+  line_number?: number;
+}
+
+export interface RgMatchEvent {
+  type: 'match';
+  data: RgLineData & {
+    path: RgPathPayload;
+    lines: RgTextPayload;
+    line_number: number;
+    submatches: RgSubmatch[];
+  };
+}
+
+export interface RgContextEvent {
+  type: 'context';
+  data: RgLineData;
+}
+
+export interface RgBeginEvent {
+  type: 'begin';
+  data: {
+    path?: RgPathPayload;
+  };
+}
+
+export interface RgEndEvent {
+  type: 'end';
+  data: {
+    path?: RgPathPayload;
+  };
+}
+
+export interface RgSummaryEvent {
+  type: 'summary';
+  data: {
+    elapsed_total?: {
+      human?: string;
+      secs: number;
+      nanos: number;
+    };
+    stats?: Record<string, unknown>;
+  };
+}
+
+export type RgJsonEvent =
+  | RgMatchEvent
+  | RgContextEvent
+  | RgBeginEvent
+  | RgEndEvent
+  | RgSummaryEvent;

+ 1 - 0
src/tools/index.ts

@@ -2,6 +2,7 @@
 export { ast_grep_replace, ast_grep_search } from './ast-grep';
 export { createBackgroundTools } from './background';
 export { createCouncilTool } from './council';
+export { createGrepTool } from './grep';
 export {
   lsp_diagnostics,
   lsp_find_references,

+ 1 - 1
src/utils/index.ts

@@ -4,4 +4,4 @@ export * from './internal-initiator';
 export { getLogDir, initLogger, log, resetLogger } from './logger';
 export * from './polling';
 export * from './session';
-export { extractZip } from './zip-extractor';
+export { extractZip, getZipExtractionSupportError } from './zip-extractor';

+ 64 - 0
src/utils/zip-extractor.ts

@@ -29,6 +29,17 @@ function escapePowerShellPath(path: string): string {
 
 type WindowsZipExtractor = 'tar' | 'pwsh' | 'powershell';
 
+function hasCommand(command: string, args: string[] = ['--version']): boolean {
+  try {
+    const result = spawnSync(command, args, {
+      stdio: ['ignore', 'ignore', 'ignore'],
+    });
+    return result.status === 0;
+  } catch {
+    return false;
+  }
+}
+
 function getWindowsZipExtractor(): WindowsZipExtractor {
   const buildNumber = getWindowsBuildNumber();
 
@@ -43,10 +54,48 @@ function getWindowsZipExtractor(): WindowsZipExtractor {
   return 'powershell';
 }
 
+export function getZipExtractionSupportError(): string | undefined {
+  if (process.platform === 'win32') {
+    const extractor = getWindowsZipExtractor();
+
+    if (extractor === 'tar' && !hasCommand('tar')) {
+      return 'ripgrep auto-install requires tar on this Windows host to extract zip archives.';
+    }
+
+    if (extractor === 'pwsh' && !hasCommand('pwsh', ['-v'])) {
+      return 'ripgrep auto-install requires pwsh to extract zip archives on this Windows host.';
+    }
+
+    if (
+      extractor === 'powershell' &&
+      !hasCommand('powershell', ['-Command', '$PSVersionTable.PSVersion.ToString()'])
+    ) {
+      return 'ripgrep auto-install requires PowerShell to extract zip archives on this Windows host.';
+    }
+
+    return undefined;
+  }
+
+  return hasCommand('unzip')
+    ? undefined
+    : 'ripgrep auto-install requires unzip to extract zip archives.';
+}
+
+function createAbortError(): Error {
+  const error = new Error('ripgrep auto-install was aborted');
+  error.name = 'AbortError';
+  return error;
+}
+
 export async function extractZip(
   archivePath: string,
   destDir: string,
+  signal?: AbortSignal,
 ): Promise<void> {
+  if (signal?.aborted) {
+    throw createAbortError();
+  }
+
   let proc: ReturnType<typeof crossSpawn>;
 
   if (process.platform === 'win32') {
@@ -93,7 +142,22 @@ export async function extractZip(
     });
   }
 
+  const onAbort = () => {
+    try {
+      proc.kill();
+    } catch {
+      // Process may have already exited.
+    }
+  };
+
+  signal?.addEventListener('abort', onAbort, { once: true });
+
   const exitCode = await proc.exited;
+  signal?.removeEventListener('abort', onAbort);
+
+  if (signal?.aborted) {
+    throw createAbortError();
+  }
 
   if (exitCode !== 0) {
     const stderr = await proc.stderr();