# v0.31.0 — Cross-file navigation, indexed file trees, and Pi grep that doesn't hang

Three new agent capabilities plus a long-overdue Pi UX fix: external-path searches no longer freeze waiting on a permission prompt that has no policy behind it.

## Trace the call path between two symbols

`aft_navigate` has a new `trace_to_symbol` op for "how does function A reach function B" queries — the single most expensive question to answer by hand. One call returns the shortest call path through every intermediate hop, with file and line for each node.

```
aft_navigate({
  op: "trace_to_symbol",
  filePath: "src/bridge.ts",
  symbol: "send",
  toSymbol: "spawn_child",
})
```

Returns either the shortest path (each hop annotated with file + line), or a structured error if the target is missing, ambiguous, or unreachable:
- `target_symbol_not_found` — name doesn't exist anywhere in the indexed graph
- `ambiguous_target` — multiple symbols share the name; rerun with `toFile` from the candidates list
- `target_symbol_not_in_file` — `toFile` provided but no matching symbol in it; candidate list returned
- `to_file_not_found` — the file you named doesn't exist
- `no_path_found` — the graph genuinely has no path

The default depth cap is 10; pass `depth` to raise it.

## `aft_outline files: true` — indexed file tree with per-file metadata

`aft_outline target: "<dir>", files: true` now returns a flat indexed file tree with language, symbol count, and byte size per file — no symbol bodies, no signatures, just the structural metadata an agent needs to pick which files to actually open next.

```
aft_outline({ target: "packages/aft-bridge/src", files: true })
```

Reuses AFT's existing symbol cache, so the call is fast even on cold bridges. The output is honest about truncation: when a directory exceeds the 200-file walk cap, the response sets `complete: false` and surfaces both `walk_truncated` and `unchecked_files` so agents don't mistake a partial tree for a complete one. Multi-target calls render every entry as a project-root-relative path, so two files named `lib.rs` from different crates can't collide in the output.

Also accepts an array of directories: `target: ["crates/aft/src", "packages/aft-bridge/src"]`.

## `aft_zoom` — cross-file batches and a polymorphic schema

`aft_zoom` is the read-the-source-of-this-symbol tool. Two changes this release:

**New: `targets` for cross-file batching.** Previous `symbols: [...]` array could only zoom into multiple symbols within the same file. The new `targets` array lets agents pull bodies from different files in one call:

```
aft_zoom({ targets: [
  { filePath: "src/a.ts", symbol: "callBridge" },
  { filePath: "src/b.ts", symbol: "spawn_child" },
]})
```

**Schema consolidation (breaking).** `symbol` and `symbols` collapse into a single polymorphic `symbols` parameter that accepts either a string or an array. Same for `targets` (single object or array). URL mode follows the same shape, so an agent can pull multiple sections from a single URL fetch:

```
aft_zoom({ url: "https://docs.example.com/api", symbols: ["Authentication", "Errors", "Examples"] })
```

The four shapes (`filePath + symbols`, `targets`, `url + symbols`, and combinations) are mutually exclusive with a clear error when mixed. Old callers using `symbol: "name"` need to migrate to `symbols: "name"`; the surface change is small but it is a break.

## Output-shape compression — fewer "what failed?" reruns

Bun, npm, and pnpm test compressors now match on the shape of the captured output, not just on the head token of the command. Wrapper invocations like `bun run --cwd packages/foo test`, `npm test`, `pnpm test`, or even `bun test && echo done` now go through the test-aware compressor instead of falling through to the generic line-dedup path. Failing test bodies and assertion diffs are preserved on the first run; the agent doesn't need to follow up with `| grep fail` to see what broke.

## Pi: external-path tool calls no longer hang

The headline Pi fix. Pi `grep`/`write`/`edit` against a path outside the project root would block the bridge indefinitely on a `ui.confirm` "Allow external directory access?" prompt — even when the user had `restrict_to_project_root: false` (the Pi default) which explicitly opts into "no path restriction."

Three causes, all addressed:

1. **No tilde expansion.** `~/Work/...` arrived in the plugin as a literal, `path.resolve(cwd, "~/...")` resolved to `<cwd>/~/...`, stat() failed, and Rust returned `path_not_found`. Both `assertExternalDirectoryPermission` and `resolvePathArg` now expand `~` / `~/foo` before any check.

2. **No `ui.confirm` timeout.** When Pi ran the call from a context that couldn't surface the prompt, the confirm promise simply never resolved. Now bounded at 30 seconds with a deterministic "Permission denied: prompt timed out" so the agent unblocks.

3. **No policy-aware skip.** When `restrict_to_project_root: false` — the Pi default and what the user explicitly opted into — the plugin used to nag anyway. Pi has no host-level `external_directory` allow-list to consult (unlike OpenCode), so the prompt had no policy behind it. The plugin now defers to Rust without prompting when the user opted into "no restriction."

Behavior matrix:

| `restrict_to_project_root` | Pi behavior |
|---|---|
| `false` (default) | Plugin defers to Rust; no prompt |
| `true` + interactive UI | `ui.confirm` with 30s timeout |
| `true` + no UI | Immediate deny with a clear error |

OpenCode's grep/glob path also gained tilde expansion, for parity. OpenCode external-directory checks already routed through the host `context.ask({permission: "external_directory"})` which the host resolves against configured rules without blocking on a UI, so the hang did not reproduce there.

## Tool descriptions: ~1.2K tokens trimmed

Dropped redundant `Returns:` blocks from `aft_transform`, `aft_import`, `aft_refactor`, `aft_safety`, `ast_grep_search`, and `ast_grep_replace` — agents see the actual response shape at runtime, no need to also restate it in the prompt. Collapsed `lsp_diagnostics` from a 700-token inline JSON schema + verbose honesty playbook to a 250-token version that keeps the load-bearing "don't claim 'no errors' when nothing was checked" rule. Standardized path-resolution wording across `filePath` / `path` / `directory` params so the surface is consistent.

Combined: 5,546 → 4,498 tokens (-18.9%) on OpenCode, 4,418 → 4,315 (-2.3%) on Pi. Pi gained less because it had no `Returns:` blocks to strip.

Per-tool, the biggest cuts:
- `aft_transform`: 788 → 543 (-31%)
- `lsp_diagnostics`: 704 → 255 (-64%)
- `aft_import`: 435 → 281 (-35%)
- `ast_grep_search`: 484 → 384 (-21%)

A separate audit-driven pass also fixed three release-blocking description bugs and seven smaller polish items: an `apply_patch` claim about atomic rollback that no longer matches the actual per-file-commit behavior, a `aft_outline({ url })` example that named the old parameter shape, and ambiguous mutual-exclusion wording in `aft_zoom`.

## Other

- `trace_to_symbol`: ambiguity recovery error now renders the full candidate list as plain text instead of swallowing it inside `data:`. Agents can re-issue the call with `toFile` immediately.
- `aft_outline files: true`: now asks OpenCode for the `external_directory` permission when the directory is outside the project, matching how other file-touching tools behave.
- `bun` output-shape compressor was claiming output from arbitrary text that happened to include a `Ran N tests across M files` summary. It now requires a structurally valid bun-test marker (`(pass)`/`(fail)` followed by name and duration) before claiming the output.
- PTY watchdog test budget tightened below the watchdog poll interval so a passing test now actually proves the wake channel beat the periodic poll, instead of just measuring overall wall-clock.
- Pi added e2e and Pi-RPC coverage for `trace_to_symbol` and `aft_outline files: true`.
