Commit 023108b79f92

Vincent Demeester <vincent@sbr.pm>
2026-02-10 17:14:03
feat: add pi extensions and oracle agent
Added extensions from laulauland/dotfiles: - file-picker: enhanced @ file browser with directory navigation, multi-select, and git-aware listing - orc-mode: orchestrator mode toggle restricting tools to subagent only, adapted to local agent names - tmux-reference: /tmux pane picker that injects pane content as prompt context via @tmux: references - shell-completions: native shell completions for pi bang commands (fish/zsh/bash) - threads: find_threads and search_thread tools for searching past pi session history via ripgrep Added oracle agent for deep analysis, debugging, and architecture decisions.
1 parent ef605a1
dots/pi/agent/agents/oracle.md
@@ -0,0 +1,47 @@
+---
+name: oracle
+description: Deep analysis, debugging, and architecture decisions
+tools: read, grep, find, ls, bash
+model: claude-sonnet-4-5
+---
+
+You are a deep analysis specialist. You investigate complex problems, debug tricky issues, and provide architecture guidance.
+
+Bash is for read-only commands only: `git log`, `git blame`, `git show`, test runs, type checking. Do NOT modify files.
+
+Strategy varies by task:
+
+**Debugging:**
+1. Understand the symptom from context provided
+2. Trace the code path using read/grep
+3. Identify root cause with evidence (specific lines, data flow)
+4. Propose a fix with rationale
+
+**Architecture:**
+1. Map the current structure (modules, dependencies, data flow)
+2. Identify constraints and trade-offs
+3. Propose options with pros/cons
+4. Recommend one with clear justification
+
+**Investigation:**
+1. Gather all relevant code and context
+2. Cross-reference behavior, tests, docs
+3. Synthesize findings into a clear picture
+
+Output format:
+
+## Question / Problem
+Restate what you're analyzing.
+
+## Findings
+Detailed evidence with file paths and line numbers:
+- `file.ts:42` - observation
+- `file.ts:100` - related observation
+
+## Analysis
+Connect the dots. Explain the why, not just the what.
+
+## Recommendation
+Clear, actionable conclusion. If multiple options exist, rank them with trade-offs.
+
+Be thorough but concise. Cite specific code, not vague references.
dots/pi/agent/extensions/shell-completions/scripts/bash-complete.bash
@@ -0,0 +1,47 @@
+#!/bin/bash
+# Gets completions using bash's native completion system
+# Usage: bash-complete.bash "command line" "/path/to/cwd"
+
+__cmdline="$1"
+__cwd="$2"
+
+cd "$__cwd" 2>/dev/null || exit 1
+
+# Extract command name
+__cmd=${__cmdline%% *}
+
+# Source bash-completion framework if available
+for f in /usr/share/bash-completion/bash_completion /etc/bash_completion /opt/homebrew/etc/bash_completion /opt/homebrew/share/bash-completion/bash_completion; do
+    [[ -f "$f" ]] && { source "$f" 2>/dev/null; break; }
+done
+
+# Also try to source command-specific completions directly (macOS/Homebrew)
+for dir in /opt/homebrew/etc/bash_completion.d /usr/share/bash-completion/completions /etc/bash_completion.d; do
+    for f in "$dir/$__cmd" "$dir/$__cmd.bash" "$dir/${__cmd}-completion.bash"; do
+        [[ -f "$f" ]] && source "$f" 2>/dev/null
+    done
+done
+
+# Set up completion environment
+COMP_LINE="$__cmdline"
+COMP_POINT=${#COMP_LINE}
+eval set -- "$COMP_LINE"
+COMP_WORDS=("$@")
+
+# Add empty word if line ends with space (completing new word)
+[[ "${COMP_LINE: -1}" = ' ' ]] && COMP_WORDS+=('')
+
+COMP_CWORD=$(( ${#COMP_WORDS[@]} - 1 ))
+
+# Load completion for the command if available
+declare -F _completion_loader &>/dev/null && _completion_loader "$__cmd" 2>/dev/null
+
+# Get the completion function
+completion=$(complete -p "$__cmd" 2>/dev/null | awk '{print $(NF-1)}')
+
+if [[ -n "$completion" ]] && declare -F "$completion" &>/dev/null; then
+    # Call the completion function
+    "$completion" 2>/dev/null
+    # Output unique results
+    printf '%s\n' "${COMPREPLY[@]}" | sort -u | head -30
+fi
dots/pi/agent/extensions/shell-completions/scripts/zsh-capture.zsh
@@ -0,0 +1,148 @@
+#!/bin/zsh
+# Simple zsh completion capture using _complete_help
+# Usage: zsh-capture.zsh "command line" "/path/to/cwd"
+
+emulate -L zsh
+setopt no_beep
+
+local cmdline="$1"
+local cwd="$2"
+
+cd "$cwd" 2>/dev/null || exit 1
+
+# Initialize completion system (use user's zcompdump)
+autoload -Uz compinit
+compinit -C 2>/dev/null
+
+# Parse command line
+local -a words
+words=("${(@Q)${(z)cmdline}}")
+
+# If line ends with space, we're completing a new word
+if [[ "$cmdline" == *" " ]]; then
+    words+=("")
+fi
+
+local cmd="${words[1]}"
+local current="${words[-1]}"
+
+# Helper to output completions
+output() {
+    local val="$1" desc="$2"
+    if [[ -n "$desc" ]]; then
+        print -r -- "${val}"$'\t'"${desc}"
+    else
+        print -r -- "${val}"
+    fi
+}
+
+# Git completions
+if [[ "$cmd" == "git" ]]; then
+    if (( ${#words} == 2 )); then
+        # Git subcommands
+        git --list-cmds=main,others 2>/dev/null | while read -r subcmd; do
+            [[ -z "$current" || "$subcmd" == "$current"* ]] && output "$subcmd"
+        done
+    else
+        local subcmd="${words[2]}"
+        case "$subcmd" in
+            checkout|switch|merge|rebase|branch|log)
+                # Branches
+                git for-each-ref --format='%(refname:short)' refs/heads 2>/dev/null | while read -r b; do
+                    [[ -z "$current" || "$b" == "$current"* ]] && output "$b" "branch"
+                done
+                git for-each-ref --format='%(refname:short)' refs/remotes 2>/dev/null | while read -r b; do
+                    [[ "$b" == */HEAD ]] && continue
+                    local short="${b#*/}"
+                    [[ -z "$current" || "$short" == "$current"* ]] && output "$short" "remote"
+                done
+                ;;
+            add|diff|restore|reset)
+                # Modified files
+                git diff --name-only 2>/dev/null | while read -r f; do
+                    [[ -z "$current" || "$f" == "$current"* ]] && output "$f" "modified"
+                done
+                git diff --cached --name-only 2>/dev/null | while read -r f; do
+                    [[ -z "$current" || "$f" == "$current"* ]] && output "$f" "staged"
+                done
+                ;;
+            push|pull|fetch)
+                if (( ${#words} == 3 )); then
+                    git remote 2>/dev/null | while read -r r; do
+                        [[ -z "$current" || "$r" == "$current"* ]] && output "$r" "remote"
+                    done
+                fi
+                ;;
+            stash)
+                for sub in apply drop list pop show push; do
+                    [[ -z "$current" || "$sub" == "$current"* ]] && output "$sub"
+                done
+                ;;
+        esac
+    fi
+    exit 0
+fi
+
+# SSH/SCP completions - hosts
+if [[ "$cmd" == "ssh" || "$cmd" == "scp" || "$cmd" == "sftp" ]]; then
+    {
+        [[ -f ~/.ssh/config ]] && awk '/^Host / && !/\*/{for(i=2;i<=NF;i++)print $i}' ~/.ssh/config
+        [[ -f ~/.ssh/known_hosts ]] && awk -F'[, ]' '{print $1}' ~/.ssh/known_hosts
+    } 2>/dev/null | sort -u | while read -r h; do
+        [[ -z "$current" || "$h" == "$current"* ]] && output "$h" "host"
+    done
+    exit 0
+fi
+
+# Make completions
+if [[ "$cmd" == "make" ]]; then
+    local mf
+    for f in GNUmakefile Makefile makefile; do
+        [[ -f "$f" ]] && mf="$f" && break
+    done
+    if [[ -n "$mf" ]]; then
+        awk -F: '/^[a-zA-Z_][a-zA-Z0-9_-]*:/ && !/^\./{print $1}' "$mf" 2>/dev/null | while read -r t; do
+            [[ -z "$current" || "$t" == "$current"* ]] && output "$t" "target"
+        done
+    fi
+    exit 0
+fi
+
+# NPM/Yarn/PNPM completions
+if [[ "$cmd" == "npm" || "$cmd" == "yarn" || "$cmd" == "pnpm" ]]; then
+    if (( ${#words} == 2 )); then
+        for sub in install add remove run build test start dev publish; do
+            [[ -z "$current" || "$sub" == "$current"* ]] && output "$sub"
+        done
+    elif [[ "${words[2]}" == "run" && -f package.json ]]; then
+        jq -r '.scripts // {} | keys[]' package.json 2>/dev/null | while read -r s; do
+            [[ -z "$current" || "$s" == "$current"* ]] && output "$s" "script"
+        done
+    fi
+    exit 0
+fi
+
+# Docker completions
+if [[ "$cmd" == "docker" ]]; then
+    if (( ${#words} == 2 )); then
+        for sub in build compose exec images logs ps pull push rm rmi run start stop; do
+            [[ -z "$current" || "$sub" == "$current"* ]] && output "$sub"
+        done
+    fi
+    exit 0
+fi
+
+# Fallback: file completion
+if [[ -n "$current" ]]; then
+    local -a matches
+    matches=( ${current}*(N) )
+    for f in "${matches[@]:0:20}"; do
+        [[ -d "$f" ]] && output "${f}/" "directory" || output "$f" "file"
+    done
+else
+    local -a matches
+    matches=( *(N) )
+    for f in "${matches[@]:0:20}"; do
+        [[ -d "$f" ]] && output "${f}/" "directory" || output "$f" "file"
+    done
+fi
dots/pi/agent/extensions/shell-completions/bash.ts
@@ -0,0 +1,125 @@
+/**
+ * Bash shell completion provider.
+ *
+ * Uses bash's native completion system by running a script that sets up
+ * COMP_* environment variables and calls the registered completion function.
+ *
+ * Philosophy: Only provide completions if the user has bash-completion available.
+ */
+
+import type { AutocompleteItem } from "@mariozechner/pi-tui";
+import { spawnSync } from "node:child_process";
+import * as fs from "node:fs";
+import * as path from "node:path";
+import { fileURLToPath } from "node:url";
+import type { CompletionResult, ShellCompletionProvider } from "./types.js";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const COMPLETE_SCRIPT = path.join(__dirname, "scripts", "bash-complete.bash");
+
+/**
+ * Check if bash-completion is available.
+ * We check for the presence of completion scripts in standard locations.
+ */
+let completionCheckCache: boolean | null = null;
+
+function userHasBashCompletions(bashPath: string): boolean {
+	if (completionCheckCache !== null) {
+		return completionCheckCache;
+	}
+
+	try {
+		// Check if bash-completion framework or git completion exists
+		const result = spawnSync(
+			bashPath,
+			[
+				"-c",
+				`
+				for f in /usr/share/bash-completion/bash_completion /etc/bash_completion /opt/homebrew/etc/bash_completion /opt/homebrew/share/bash-completion/bash_completion; do
+					[[ -f "$f" ]] && { echo yes; exit 0; }
+				done
+				# Also check for individual completion files
+				for f in /opt/homebrew/etc/bash_completion.d/git* /usr/share/bash-completion/completions/git; do
+					[[ -f "$f" ]] && { echo yes; exit 0; }
+				done
+				echo no
+			`,
+			],
+			{
+				encoding: "utf-8",
+				timeout: 500,
+			}
+		);
+
+		completionCheckCache = result.stdout?.trim() === "yes";
+		return completionCheckCache;
+	} catch {
+		completionCheckCache = false;
+		return false;
+	}
+}
+
+/**
+ * Get completions using bash's native completion system.
+ */
+export function getBashCompletions(
+	commandLine: string,
+	cwd: string,
+	bashPath: string
+): CompletionResult | null {
+	// Check if bash completions are available
+	if (!userHasBashCompletions(bashPath)) {
+		return null;
+	}
+
+	// Check if completion script exists
+	if (!fs.existsSync(COMPLETE_SCRIPT)) {
+		return null;
+	}
+
+	// Extract prefix
+	const trimmed = commandLine.trimStart();
+	let prefix = "";
+	if (!trimmed.endsWith(" ")) {
+		const words = trimmed.split(/\s+/);
+		prefix = words[words.length - 1] || "";
+	}
+
+	try {
+		const result = spawnSync(bashPath, [COMPLETE_SCRIPT, commandLine, cwd], {
+			encoding: "utf-8",
+			timeout: 500,
+			maxBuffer: 1024 * 100,
+			cwd,
+		});
+
+		if (result.error || !result.stdout) {
+			return null;
+		}
+
+		const items: AutocompleteItem[] = result.stdout
+			.trim()
+			.split("\n")
+			.filter(Boolean)
+			.map((line) => {
+				// Remove trailing space that bash completion adds
+				const value = line.trimEnd();
+				return { value, label: value };
+			});
+
+		if (items.length === 0) {
+			return null;
+		}
+
+		return {
+			items: items.slice(0, 30),
+			prefix,
+		};
+	} catch {
+		return null;
+	}
+}
+
+export const bashCompletionProvider: ShellCompletionProvider = {
+	getCompletions: getBashCompletions,
+};
dots/pi/agent/extensions/shell-completions/fish.ts
@@ -0,0 +1,84 @@
+/**
+ * Fish shell completion provider.
+ *
+ * Uses fish's native `complete -C` command which provides excellent completions
+ * for most tools automatically.
+ * 
+ * Fish always has completions available (it's a core feature), so this never
+ * returns null for "user hasn't configured completions".
+ */
+
+import type { AutocompleteItem } from "@mariozechner/pi-tui";
+import { spawnSync } from "node:child_process";
+import type { CompletionResult, ShellCompletionProvider } from "./types.js";
+
+/**
+ * Get completions using fish's native `complete -C` command.
+ * Fish completions are excellent and cover most tools automatically.
+ */
+export function getFishCompletions(
+	commandLine: string,
+	cwd: string,
+	fishPath: string
+): CompletionResult | null {
+	// Extract prefix
+	const trimmed = commandLine.trimStart();
+	let prefix = "";
+	if (!trimmed.endsWith(" ")) {
+		const words = trimmed.split(/\s+/);
+		prefix = words[words.length - 1] || "";
+	}
+
+	try {
+		// Fish's complete -C gives us completions directly
+		const result = spawnSync(
+			fishPath,
+			["-c", `complete -C ${JSON.stringify(commandLine)}`],
+			{
+				encoding: "utf-8",
+				timeout: 500,
+				maxBuffer: 1024 * 100,
+				cwd,
+			}
+		);
+
+		if (result.error || !result.stdout) {
+			return null;
+		}
+
+		// Fish output format: "completion\tdescription" (tab-separated)
+		const lines = result.stdout.trim().split("\n").filter(Boolean);
+		const items: AutocompleteItem[] = [];
+
+		for (const line of lines) {
+			const tabIndex = line.indexOf("\t");
+			if (tabIndex >= 0) {
+				const value = line.slice(0, tabIndex).trim();
+				const description = line.slice(tabIndex + 1).trim();
+				if (value) {
+					items.push({ value, label: value, description });
+				}
+			} else {
+				const value = line.trim();
+				if (value) {
+					items.push({ value, label: value });
+				}
+			}
+		}
+
+		if (items.length === 0) {
+			return null;
+		}
+
+		return {
+			items: items.slice(0, 30),
+			prefix,
+		};
+	} catch {
+		return null;
+	}
+}
+
+export const fishCompletionProvider: ShellCompletionProvider = {
+	getCompletions: getFishCompletions,
+};
dots/pi/agent/extensions/shell-completions/index.ts
@@ -0,0 +1,330 @@
+/**
+ * Shell Completions Extension for Pi
+ *
+ * Adds native shell completions (fish/zsh/bash) to pi's `!` and `!!` bash mode.
+ * Uses the user's actual shell completion configuration - if they haven't
+ * set up completions, we don't provide them (no magic).
+ *
+ * Usage: Place in ~/.pi/agent/extensions/shell-completions/index.ts
+ *
+ * Shell priority:
+ * 1. User's $SHELL (if fish/zsh/bash) - uses their configured completions
+ * 2. Fish (if available) - always has completions (core feature)
+ * 3. Zsh (if compinit is configured)
+ * 4. Bash (if bash-completion is installed)
+ *
+ * Philosophy: Don't magically provide completions the user hasn't configured.
+ * This means completions respect the user's shell setup, aliases, and customizations.
+ */
+
+import { CustomEditor, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import type { AutocompleteItem, AutocompleteProvider } from "@mariozechner/pi-tui";
+import * as fs from "node:fs";
+import * as path from "node:path";
+
+import type { ShellInfo, ShellType, CompletionResult } from "./types.js";
+import { getFishCompletions } from "./fish.js";
+import { getBashCompletions } from "./bash.js";
+import { getZshCompletions } from "./zsh.js";
+
+// ============================================================================
+// Shell Detection
+// ============================================================================
+
+/**
+ * Detect shell type from path.
+ */
+function detectShellType(shellPath: string): ShellType {
+	const name = path.basename(shellPath);
+	if (name === "fish" || name.startsWith("fish")) return "fish";
+	if (name === "zsh" || name.startsWith("zsh")) return "zsh";
+	return "bash";
+}
+
+/**
+ * Find a shell suitable for running completion scripts.
+ *
+ * Priority:
+ * 1. User's $SHELL if it's fish/zsh/bash (respects user's configured completions)
+ * 2. Fish if available (best completion UX)
+ * 3. Zsh if available
+ * 4. Bash as fallback
+ */
+function findCompletionShell(): ShellInfo {
+	// First, try user's $SHELL - they've configured their completions there
+	const userShell = process.env.SHELL;
+	if (userShell && fs.existsSync(userShell)) {
+		const shellType = detectShellType(userShell);
+		// Only use it if it's a shell we support (fish/zsh/bash)
+		if (shellType === "fish" || shellType === "zsh" || shellType === "bash") {
+			return { path: userShell, type: shellType };
+		}
+	}
+
+	// If user's shell isn't suitable, prefer fish for best completions
+	const fishPaths = [
+		"/opt/homebrew/bin/fish",
+		"/usr/local/bin/fish",
+		"/usr/bin/fish",
+		"/bin/fish",
+	];
+	for (const fishPath of fishPaths) {
+		if (fs.existsSync(fishPath)) {
+			return { path: fishPath, type: "fish" };
+		}
+	}
+
+	// Then zsh
+	const zshPaths = [
+		"/bin/zsh",
+		"/usr/bin/zsh",
+		"/usr/local/bin/zsh",
+		"/opt/homebrew/bin/zsh",
+	];
+	for (const zshPath of zshPaths) {
+		if (fs.existsSync(zshPath)) {
+			return { path: zshPath, type: "zsh" };
+		}
+	}
+
+	// Bash fallback
+	const bashPaths = [
+		"/bin/bash",
+		"/usr/bin/bash",
+		"/usr/local/bin/bash",
+		"/opt/homebrew/bin/bash",
+	];
+	for (const bashPath of bashPaths) {
+		if (fs.existsSync(bashPath)) {
+			return { path: bashPath, type: "bash" };
+		}
+	}
+
+	return { path: "/bin/bash", type: "bash" };
+}
+
+// ============================================================================
+// Completion Context Extraction
+// ============================================================================
+
+/**
+ * Extract the command line and completion prefix from editor text.
+ */
+function extractCompletionContext(text: string): {
+	commandLine: string;
+	prefix: string;
+} {
+	// Remove ! or !! prefix
+	let commandLine = text.trimStart();
+	if (commandLine.startsWith("!!")) {
+		commandLine = commandLine.slice(2);
+	} else if (commandLine.startsWith("!")) {
+		commandLine = commandLine.slice(1);
+	}
+
+	const trimmed = commandLine.trimStart();
+
+	// If ends with space, completing a new word
+	if (trimmed.endsWith(" ")) {
+		return { commandLine: trimmed, prefix: "" };
+	}
+
+	// Last word is the prefix
+	const words = trimmed.split(/\s+/);
+	const prefix = words[words.length - 1] || "";
+
+	return { commandLine: trimmed, prefix };
+}
+
+// ============================================================================
+// Shell Completion Dispatcher
+// ============================================================================
+
+/**
+ * Get shell completions for a command line.
+ * Returns null if the user hasn't configured completions for their shell.
+ */
+function getShellCompletions(
+	text: string,
+	cwd: string,
+	shell: ShellInfo
+): CompletionResult | null {
+	const { commandLine } = extractCompletionContext(text);
+
+	if (!commandLine.trim()) {
+		return null;
+	}
+
+	// Each shell provider checks if user has completions configured
+	// and returns null if not
+	switch (shell.type) {
+		case "fish":
+			// Fish always has completions (it's a core feature)
+			return getFishCompletions(commandLine, cwd, shell.path);
+		case "bash":
+			// Bash: only works if bash-completion is available
+			return getBashCompletions(commandLine, cwd, shell.path);
+		case "zsh":
+			// Zsh: only works if user has compinit in their .zshrc
+			return getZshCompletions(commandLine, cwd, shell.path);
+		default:
+			return null;
+	}
+}
+
+// ============================================================================
+// Shell-Aware Autocomplete Provider Wrapper
+// ============================================================================
+
+/**
+ * Wraps an existing autocomplete provider to add shell completion support
+ * when in bash mode (text starts with ! or !!).
+ */
+function wrapWithShellCompletion(
+	baseProvider: AutocompleteProvider,
+	shell: ShellInfo
+): AutocompleteProvider {
+	const isBashMode = (lines: string[]): boolean => {
+		const text = lines.join("\n").trimStart();
+		return text.startsWith("!") || text.startsWith("!!");
+	};
+
+	const getTextUpToCursor = (
+		lines: string[],
+		cursorLine: number,
+		cursorCol: number
+	): string => {
+		const textLines = lines.slice(0, cursorLine + 1);
+		if (textLines.length > 0) {
+			textLines[textLines.length - 1] = textLines[textLines.length - 1].slice(0, cursorCol);
+		}
+		return textLines.join("\n");
+	};
+
+	return {
+		getSuggestions(
+			lines: string[],
+			cursorLine: number,
+			cursorCol: number
+		): { items: AutocompleteItem[]; prefix: string } | null {
+			if (isBashMode(lines)) {
+				const text = getTextUpToCursor(lines, cursorLine, cursorCol);
+				const result = getShellCompletions(text, process.cwd(), shell);
+				if (result && result.items.length > 0) {
+					return result;
+				}
+			}
+			return baseProvider.getSuggestions(lines, cursorLine, cursorCol);
+		},
+
+		applyCompletion(
+			lines: string[],
+			cursorLine: number,
+			cursorCol: number,
+			item: AutocompleteItem,
+			prefix: string
+		): { lines: string[]; cursorLine: number; cursorCol: number } {
+			if (isBashMode(lines)) {
+				const currentLine = lines[cursorLine] || "";
+				const prefixStart = cursorCol - prefix.length;
+				const beforePrefix = currentLine.slice(0, prefixStart);
+				const afterCursor = currentLine.slice(cursorCol);
+
+				// Don't add space after directories
+				const isDirectory = item.value.endsWith("/");
+				const suffix = isDirectory ? "" : " ";
+
+				const newLine = beforePrefix + item.value + suffix + afterCursor;
+				const newLines = [...lines];
+				newLines[cursorLine] = newLine;
+
+				return {
+					lines: newLines,
+					cursorLine,
+					cursorCol: prefixStart + item.value.length + suffix.length,
+				};
+			}
+
+			return baseProvider.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
+		},
+
+		// Forward optional methods
+		getForceFileSuggestions(
+			lines: string[],
+			cursorLine: number,
+			cursorCol: number
+		): { items: AutocompleteItem[]; prefix: string } | null {
+			if (isBashMode(lines)) {
+				const text = getTextUpToCursor(lines, cursorLine, cursorCol);
+				return getShellCompletions(text, process.cwd(), shell);
+			}
+			if ("getForceFileSuggestions" in baseProvider) {
+				return (baseProvider as any).getForceFileSuggestions(lines, cursorLine, cursorCol);
+			}
+			return this.getSuggestions(lines, cursorLine, cursorCol);
+		},
+
+		shouldTriggerFileCompletion(
+			lines: string[],
+			cursorLine: number,
+			cursorCol: number
+		): boolean {
+			if (isBashMode(lines)) {
+				return true;
+			}
+			if ("shouldTriggerFileCompletion" in baseProvider) {
+				return (baseProvider as any).shouldTriggerFileCompletion(lines, cursorLine, cursorCol);
+			}
+			return true;
+		},
+	};
+}
+
+// ============================================================================
+// Custom Editor with Shell Completion
+// ============================================================================
+
+/**
+ * Custom editor that intercepts setAutocompleteProvider to wrap with shell completion.
+ */
+class ShellCompletionEditor extends CustomEditor {
+	private shell: ShellInfo;
+	private wrappedProvider = false;
+
+	constructor(tui: any, theme: any, keybindings: any, shell: ShellInfo) {
+		super(tui, theme, keybindings);
+		this.shell = shell;
+	}
+
+	// Override setAutocompleteProvider to wrap the base provider
+	setAutocompleteProvider(provider: AutocompleteProvider): void {
+		if (!this.wrappedProvider && provider) {
+			// Wrap the provider with shell completion support
+			const wrapped = wrapWithShellCompletion(provider, this.shell);
+			super.setAutocompleteProvider(wrapped);
+			this.wrappedProvider = true;
+		} else {
+			super.setAutocompleteProvider(provider);
+		}
+	}
+}
+
+// ============================================================================
+// Extension Entry Point
+// ============================================================================
+
+export default function (pi: ExtensionAPI) {
+	const shell = findCompletionShell();
+	const shellName = path.basename(shell.path);
+
+	pi.on("session_start", (_event, ctx) => {
+		ctx.ui.setEditorComponent((tui, theme, keybindings) => {
+			return new ShellCompletionEditor(tui, theme, keybindings, shell);
+		});
+
+		ctx.ui.notify(`Shell completions enabled (${shellName})`, "info");
+	});
+}
+
+// Re-export types for potential external use
+export type { ShellInfo, ShellType, CompletionResult } from "./types.js";
dots/pi/agent/extensions/shell-completions/package.json
@@ -0,0 +1,20 @@
+{
+  "name": "pi-shell-completions",
+  "version": "0.2.0",
+  "description": "Pi extension that adds native shell completions (fish/zsh/bash) to ! and !! bash mode commands",
+  "type": "module",
+  "keywords": ["pi-package"],
+  "license": "MIT",
+  "author": "laulauland",
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/laulauland/dotfiles"
+  },
+  "pi": {
+    "extensions": ["./index.ts"]
+  },
+  "peerDependencies": {
+    "@mariozechner/pi-coding-agent": "*",
+    "@mariozechner/pi-tui": "*"
+  }
+}
dots/pi/agent/extensions/shell-completions/README.md
@@ -0,0 +1,70 @@
+# pi-shell-completions
+
+Adds native shell completions to pi's `!` and `!!` bash mode commands.
+
+## Installation
+
+```bash
+pi install npm:pi-shell-completions
+```
+
+Or for local development, place in `~/.pi/agent/extensions/shell-completions/`
+
+## How it works
+
+When you type `!git checkout ` in pi's prompt, this extension queries your shell's completion system and shows suggestions.
+
+### Shell support
+
+| Shell | How it works | Quality |
+|-------|--------------|---------|
+| **Fish** | Native `complete -C` command | ⭐⭐⭐ Excellent - all completions work |
+| **Bash** | Sources bash-completion scripts | ⭐⭐ Good - if bash-completion is installed |
+| **Zsh** | Fallback script for common tools | ⭐ Basic - see limitations |
+
+### Fish (recommended)
+
+Fish's completion system is designed to be queried programmatically via `complete -C "command "`. This means:
+
+- All your fish completions work automatically
+- Git branches, docker containers, ssh hosts, npm scripts — everything
+- Descriptions are included
+- Fast (10-30ms)
+
+Even if fish isn't your primary shell, installing it gives you great completions in pi.
+
+### Bash
+
+Bash-completion can be queried by setting up `COMP_*` environment variables and calling completion functions. This extension:
+
+- Sources completion scripts from standard locations (`/opt/homebrew/etc/bash_completion.d/`, `/usr/share/bash-completion/completions/`, etc.)
+- Calls the registered completion function for each command
+- Works if you have bash-completion installed
+
+### Zsh (limited)
+
+Zsh's completion system is tightly coupled to its line editor (ZLE) and cannot be easily queried programmatically. The `zpty` pseudo-terminal approach is complex and unreliable.
+
+**Current limitations:**
+- Does NOT use your full zsh completion config
+- Only handles common tools: git, ssh, make, npm/yarn/pnpm, docker
+- Falls back to file completion for other commands
+
+**Recommendation:** If you use zsh and want good completions in pi, install fish as a secondary shell. The extension will automatically prefer fish when available.
+
+## Shell priority
+
+1. Your `$SHELL` (if fish/zsh/bash)
+2. Fish (if available) — even if not your primary shell
+3. Zsh
+4. Bash
+
+## Requirements
+
+- One of: fish, zsh, or bash
+- For bash: bash-completion package installed
+- For best experience: fish
+
+## License
+
+MIT
dots/pi/agent/extensions/shell-completions/types.ts
@@ -0,0 +1,30 @@
+/**
+ * Shared types for shell completions extension.
+ */
+
+import type { AutocompleteItem } from "@mariozechner/pi-tui";
+
+export type ShellType = "fish" | "zsh" | "bash";
+
+export interface ShellInfo {
+	path: string;
+	type: ShellType;
+}
+
+export interface CompletionContext {
+	commandLine: string;
+	prefix: string;
+}
+
+export interface CompletionResult {
+	items: AutocompleteItem[];
+	prefix: string;
+}
+
+/**
+ * Interface for shell-specific completion providers.
+ * Returns null if the user hasn't configured completions for this shell.
+ */
+export interface ShellCompletionProvider {
+	getCompletions(commandLine: string, cwd: string, shellPath: string): CompletionResult | null;
+}
dots/pi/agent/extensions/shell-completions/zsh.ts
@@ -0,0 +1,110 @@
+/**
+ * Zsh completion support
+ *
+ * Unfortunately, zsh's completion system is tightly coupled to its line editor
+ * (ZLE) and cannot be easily queried programmatically without a pseudo-terminal.
+ * The zpty approach is complex and fragile.
+ *
+ * This implementation uses a simple fallback script that handles common cases
+ * (git, ssh, make, npm, docker) but does NOT tap into the user's full zsh
+ * completion configuration.
+ *
+ * For the best experience, install fish (even as a secondary shell) - its
+ * `complete -C` command provides excellent completions without complexity.
+ */
+
+import { spawnSync } from "node:child_process";
+import * as fs from "node:fs";
+import * as path from "node:path";
+import { fileURLToPath } from "node:url";
+import type { CompletionResult } from "./types.js";
+import type { AutocompleteItem } from "@mariozechner/pi-tui";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const CAPTURE_SCRIPT = path.join(__dirname, "scripts", "zsh-capture.zsh");
+
+/**
+ * Parse completion output (tab-separated: value\tdescription)
+ */
+function parseOutput(output: string): AutocompleteItem[] {
+	const lines = output.trim().split("\n").filter(Boolean);
+	const items: AutocompleteItem[] = [];
+	const seen = new Set<string>();
+
+	for (const line of lines) {
+		const tabIndex = line.indexOf("\t");
+		let value: string;
+		let description: string | undefined;
+
+		if (tabIndex >= 0) {
+			value = line.slice(0, tabIndex).trim();
+			description = line.slice(tabIndex + 1).trim() || undefined;
+		} else {
+			value = line.trim();
+		}
+
+		if (!value || seen.has(value)) continue;
+		seen.add(value);
+
+		// Skip internal refs
+		if (value.startsWith("refs/jj/keep/")) continue;
+
+		items.push({ value, label: value, description });
+	}
+
+	return items;
+}
+
+/**
+ * Get completions using zsh fallback script.
+ * Note: This does NOT use the user's full zsh completion config.
+ */
+export function getZshCompletions(
+	commandLine: string,
+	cwd: string,
+	zshPath: string
+): CompletionResult | null {
+	// Check if capture script exists
+	if (!fs.existsSync(CAPTURE_SCRIPT)) {
+		return null;
+	}
+
+	// Extract prefix
+	const trimmed = commandLine.trimStart();
+	let prefix = "";
+	if (!trimmed.endsWith(" ")) {
+		const words = trimmed.split(/\s+/);
+		prefix = words[words.length - 1] || "";
+	}
+
+	try {
+		const result = spawnSync(zshPath, [CAPTURE_SCRIPT, commandLine, cwd], {
+			encoding: "utf-8",
+			timeout: 500,
+			maxBuffer: 1024 * 100,
+			cwd,
+		});
+
+		if (result.error || !result.stdout) {
+			return null;
+		}
+
+		const items = parseOutput(result.stdout);
+
+		if (items.length === 0) {
+			return null;
+		}
+
+		return {
+			items: items.slice(0, 30),
+			prefix,
+		};
+	} catch {
+		return null;
+	}
+}
+
+export const zshCompletionProvider = {
+	name: "zsh" as const,
+	getCompletions: getZshCompletions,
+};
dots/pi/agent/extensions/threads/index.ts
@@ -0,0 +1,608 @@
+/**
+ * Thread Search and Reading Extension
+ *
+ * Provides find_threads and search_thread tools for searching and reading
+ * past conversation sessions.
+ */
+
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import { Type } from "@sinclair/typebox";
+import { StringEnum } from "@mariozechner/pi-ai";
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import {
+	parseSessionEntries,
+	type FileEntry,
+	type SessionEntry,
+	type SessionHeader,
+} from "@mariozechner/pi-coding-agent";
+import { Text, Container, Spacer } from "@mariozechner/pi-tui";
+
+// ============================================================================
+// Session Parsing Utilities
+// ============================================================================
+
+function getSessionsDir(): string {
+	return path.join(os.homedir(), ".pi", "agent", "sessions");
+}
+
+function loadSessionFile(filePath: string): FileEntry[] {
+	const content = fs.readFileSync(filePath, "utf-8");
+	return parseSessionEntries(content);
+}
+
+function getSessionHeader(entries: FileEntry[]): SessionHeader | null {
+	return entries.find((e): e is SessionHeader => e.type === "session") ?? null;
+}
+
+function getSessionEntries(entries: FileEntry[]): SessionEntry[] {
+	return entries.filter((e): e is SessionEntry => e.type !== "session");
+}
+
+function getLeafEntry(entries: SessionEntry[]): SessionEntry | null {
+	if (entries.length === 0) return null;
+
+	const parentIds = new Set<string>();
+	for (const entry of entries) {
+		if ("parentId" in entry && entry.parentId) {
+			parentIds.add(entry.parentId);
+		}
+	}
+
+	for (let i = entries.length - 1; i >= 0; i--) {
+		const entry = entries[i]!;
+		if (!parentIds.has(entry.id)) {
+			return entry;
+		}
+	}
+
+	return entries[entries.length - 1] ?? null;
+}
+
+function getEntryPath(entries: SessionEntry[]): SessionEntry[] {
+	const leaf = getLeafEntry(entries);
+	if (!leaf) return [];
+
+	const byId = new Map(entries.map((entry) => [entry.id, entry]));
+	const path: SessionEntry[] = [];
+	let current: SessionEntry | undefined = leaf;
+
+	while (current) {
+		path.push(current);
+		const parentId = "parentId" in current ? current.parentId : null;
+		if (!parentId) break;
+		current = byId.get(parentId);
+	}
+
+	return path.reverse();
+}
+
+function extractTextContent(content: unknown): string {
+	if (typeof content === "string") return content;
+	if (Array.isArray(content)) {
+		const parts: string[] = [];
+		for (const part of content) {
+			if (part.type === "text" && part.text) {
+				parts.push(part.text);
+			} else if (part.type === "toolCall" && part.name) {
+				parts.push(`[Tool: ${part.name}]`);
+			}
+		}
+		return parts.join("\n");
+	}
+	return "";
+}
+
+function getFirstUserMessage(entries: SessionEntry[]): string {
+	for (const entry of entries) {
+		if (entry.type === "message" && entry.message.role === "user") {
+			const text = extractTextContent(entry.message.content);
+			if (text) return text.slice(0, 200);
+		}
+	}
+	return "(no user message)";
+}
+
+function countMessages(entries: SessionEntry[]): number {
+	return entries.filter((e) => e.type === "message").length;
+}
+
+// ============================================================================
+// Search Functions
+// ============================================================================
+
+async function searchWithGrep(
+	exec: (cmd: string, args: string[], opts?: { timeout?: number }) => Promise<{ stdout: string; stderr: string; code: number }>,
+	query: string,
+	sessionsDir: string,
+	onFallback?: () => void,
+): Promise<Map<string, number>> {
+	const results = new Map<string, number>();
+
+	// Try ripgrep first
+	try {
+		const { stdout, code } = await exec("rg", ["-c", "-i", "--", query, sessionsDir], { timeout: 10000 });
+		if (code === 0 || code === 1) { // 1 = no matches, which is fine
+			for (const line of stdout.split("\n")) {
+				if (!line.trim()) continue;
+				const match = line.match(/^(.+):(\d+)$/);
+				if (match) results.set(match[1], parseInt(match[2], 10));
+			}
+			return results;
+		}
+	} catch {
+		// ripgrep not found or failed, fall back to grep
+	}
+
+	// Fallback to grep
+	onFallback?.();
+	try {
+		const { stdout } = await exec("grep", ["-r", "-c", "-i", query, sessionsDir], { timeout: 30000 });
+		for (const line of stdout.split("\n")) {
+			if (!line.trim()) continue;
+			const match = line.match(/^(.+):(\d+)$/);
+			if (match && parseInt(match[2], 10) > 0) {
+				results.set(match[1], parseInt(match[2], 10));
+			}
+		}
+	} catch {
+		// grep also failed or no matches
+	}
+	return results;
+}
+
+async function getAllSessions(sessionsDir: string): Promise<string[]> {
+	const sessions: string[] = [];
+	if (!fs.existsSync(sessionsDir)) return sessions;
+
+	for (const dirEntry of fs.readdirSync(sessionsDir, { withFileTypes: true })) {
+		if (!dirEntry.isDirectory() || dirEntry.name.startsWith(".")) continue;
+		const dirPath = path.join(sessionsDir, dirEntry.name);
+		for (const fileEntry of fs.readdirSync(dirPath, { withFileTypes: true })) {
+			if (fileEntry.name.endsWith(".jsonl")) {
+				sessions.push(path.join(dirPath, fileEntry.name));
+			}
+		}
+	}
+	return sessions;
+}
+
+// ============================================================================
+// Extension
+// ============================================================================
+
+const FindThreadsParams = Type.Object({
+	query: Type.Optional(Type.String({ description: "Text to search for in messages (uses ripgrep)" })),
+	cwd: Type.Optional(Type.String({ description: "Filter by working directory (partial match)" })),
+	limit: Type.Optional(Type.Number({ description: "Maximum results to return (default: 10)", default: 10 })),
+	sort: Type.Optional(
+		StringEnum(["recent", "oldest", "relevance"] as const, {
+			description: "Sort order: recent (default), oldest, or relevance (by match count)",
+			default: "recent",
+		}),
+	),
+});
+
+const SearchThreadParams = Type.Object({
+	thread_id: Type.String({ description: "Thread ID (session UUID) or file path" }),
+	query: Type.Optional(Type.String({ description: "Search for messages containing this text (case-insensitive). If omitted, returns all messages." })),
+	context: Type.Optional(Type.Number({ description: "Include N messages before/after each match (default: 0)", default: 0 })),
+	roles: Type.Optional(Type.Array(Type.String(), { description: "Filter to specific roles: user, assistant, toolResult (default: all)" })),
+	max_messages: Type.Optional(Type.Number({ description: "Maximum messages to return" })),
+	max_content_length: Type.Optional(Type.Number({ description: "Truncate each message content to N chars" })),
+});
+
+export default function (pi: ExtensionAPI) {
+	// ========================================================================
+	// find_threads tool
+	// ========================================================================
+	pi.registerTool({
+		name: "find_threads",
+		label: "Find Threads",
+		description:
+			"Search through past conversation sessions. Use to find previous discussions, code changes, or decisions. Searches message content using ripgrep for speed.",
+		parameters: FindThreadsParams,
+
+		async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
+			const startTime = Date.now();
+			const sessionsDir = getSessionsDir();
+			const limit = params.limit ?? 10;
+			const sort = params.sort ?? "recent";
+
+			let sessionFiles = await getAllSessions(sessionsDir);
+			let matchCounts: Map<string, number> | null = null;
+
+			// Filter by query using ripgrep (with grep fallback)
+			if (params.query) {
+				matchCounts = await searchWithGrep(
+					pi.exec.bind(pi),
+					params.query,
+					sessionsDir,
+					() => ctx.ui.notify("ripgrep not found, falling back to grep (slower)", "warning"),
+				);
+				sessionFiles = sessionFiles.filter((f) => matchCounts!.has(f));
+			}
+
+			// Filter by cwd
+			if (params.cwd) {
+				const cwdFilter = params.cwd.toLowerCase();
+				sessionFiles = sessionFiles.filter((f) => {
+					const entries = loadSessionFile(f);
+					const header = getSessionHeader(entries);
+					return header?.cwd?.toLowerCase().includes(cwdFilter);
+				});
+			}
+
+			// Parse and build results
+			const results: Array<{
+				id: string;
+				cwd: string;
+				timestamp: string;
+				preview: string;
+				messageCount: number;
+				filePath: string;
+				matchCount?: number;
+			}> = [];
+
+			for (const filePath of sessionFiles) {
+				const entries = loadSessionFile(filePath);
+				const header = getSessionHeader(entries);
+				if (!header) continue;
+
+				const sessionEntries = getSessionEntries(entries);
+				results.push({
+					id: header.id,
+					cwd: header.cwd || "",
+					timestamp: header.timestamp,
+					preview: getFirstUserMessage(sessionEntries),
+					messageCount: countMessages(sessionEntries),
+					filePath,
+					matchCount: matchCounts?.get(filePath),
+				});
+			}
+
+			// Sort
+			if (sort === "recent") {
+				results.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
+			} else if (sort === "oldest") {
+				results.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
+			} else if (sort === "relevance" && matchCounts) {
+				results.sort((a, b) => (b.matchCount ?? 0) - (a.matchCount ?? 0));
+			}
+
+			const limitedResults = results.slice(0, limit);
+			const searchTime = Date.now() - startTime;
+
+			// Format text output
+			let text = `Found ${results.length} threads`;
+			if (params.query) text += ` matching "${params.query}"`;
+			if (params.cwd) text += ` in ${params.cwd}`;
+			text += ` (${searchTime}ms)\n\n`;
+
+			for (const r of limitedResults) {
+				const date = new Date(r.timestamp).toLocaleDateString();
+				text += `**${r.id}** (${date})\n`;
+				text += `  📁 ${r.cwd}\n`;
+				text += `  💬 ${r.messageCount} messages`;
+				if (r.matchCount) text += ` | ${r.matchCount} matches`;
+				text += `\n  📝 ${r.preview}\n\n`;
+			}
+
+			if (results.length > limit) {
+				text += `... and ${results.length - limit} more. Use limit parameter to see more.`;
+			}
+
+			return {
+				content: [{ type: "text", text }],
+				details: { threads: limitedResults, searchTime, totalSessions: sessionFiles.length },
+			};
+		},
+
+		renderCall(args, theme) {
+			let text = theme.fg("toolTitle", theme.bold("find_threads"));
+			if (args.query) text += " " + theme.fg("accent", `"${args.query}"`);
+			if (args.cwd) text += " " + theme.fg("muted", `in ${args.cwd}`);
+			if (args.limit) text += " " + theme.fg("dim", `limit:${args.limit}`);
+			return new Text(text, 0, 0);
+		},
+
+		renderResult(result, { expanded }, theme) {
+			const { details } = result;
+			if (!details?.threads) {
+				const text = result.content[0];
+				return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
+			}
+
+			const { threads, searchTime } = details;
+			const icon = threads.length > 0 ? theme.fg("success", "✓") : theme.fg("muted", "○");
+
+			if (expanded) {
+				const container = new Container();
+				container.addChild(
+					new Text(`${icon} Found ${theme.fg("accent", String(threads.length))} threads (${searchTime}ms)`, 0, 0),
+				);
+
+				for (const t of threads) {
+					container.addChild(new Spacer(1));
+					const date = new Date(t.timestamp).toLocaleDateString();
+					container.addChild(new Text(theme.fg("accent", t.id) + theme.fg("dim", ` (${date})`), 0, 0));
+					container.addChild(new Text(theme.fg("muted", `  📁 ${t.cwd}`), 0, 0));
+					container.addChild(
+						new Text(
+							theme.fg("dim", `  💬 ${t.messageCount} msgs`) +
+								(t.matchCount ? theme.fg("warning", ` | ${t.matchCount} matches`) : ""),
+							0,
+							0,
+						),
+					);
+					const preview = t.preview.length > 80 ? t.preview.slice(0, 80) + "..." : t.preview;
+					container.addChild(new Text(theme.fg("toolOutput", `  ${preview}`), 0, 0));
+				}
+				return container;
+			}
+
+			// Collapsed view
+			let text = `${icon} Found ${theme.fg("accent", String(threads.length))} threads (${searchTime}ms)`;
+			for (const t of threads.slice(0, 3)) {
+				const date = new Date(t.timestamp).toLocaleDateString();
+				const preview = t.preview.length > 50 ? t.preview.slice(0, 50) + "..." : t.preview;
+				text += `\n  ${theme.fg("accent", t.id.slice(0, 8))} ${theme.fg("dim", date)} ${theme.fg("muted", preview)}`;
+			}
+			if (threads.length > 3) {
+				text += `\n  ${theme.fg("muted", `... +${threads.length - 3} more (Ctrl+O to expand)`)}`;
+			}
+			return new Text(text, 0, 0);
+		},
+	});
+
+	// ========================================================================
+	// search_thread tool
+	// ========================================================================
+	pi.registerTool({
+		name: "search_thread",
+		label: "Search Thread",
+		description:
+			"Search and read a specific conversation thread by ID or file path. Returns conversation messages with optional filtering by query text, roles, and context window.",
+		parameters: SearchThreadParams,
+
+		async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
+			const { thread_id, query, context = 0, roles, max_messages, max_content_length } = params;
+			const sessionsDir = getSessionsDir();
+
+			// Find the session file
+			let filePath: string | null = null;
+
+			if (thread_id.endsWith(".jsonl") || thread_id.startsWith("/")) {
+				filePath = thread_id;
+			} else {
+				const allSessions = await getAllSessions(sessionsDir);
+				for (const sessionPath of allSessions) {
+					const entries = loadSessionFile(sessionPath);
+					const header = getSessionHeader(entries);
+					if (header?.id === thread_id) {
+						filePath = sessionPath;
+						break;
+					}
+				}
+			}
+
+			if (!filePath || !fs.existsSync(filePath)) {
+				return {
+					content: [{ type: "text", text: `Thread not found: ${thread_id}` }],
+					details: { thread: null, error: "Thread not found" },
+					isError: true,
+				};
+			}
+
+			const fileEntries = loadSessionFile(filePath);
+			const header = getSessionHeader(fileEntries);
+
+			if (!header) {
+				return {
+					content: [{ type: "text", text: `Invalid session file: ${filePath}` }],
+					details: { thread: null, error: "Invalid session file" },
+					isError: true,
+				};
+			}
+
+			const sessionEntries = getSessionEntries(fileEntries);
+			const branchEntries = getEntryPath(sessionEntries);
+
+			// Build message list
+			const allMessages: Array<{
+				role: string;
+				content: string;
+				timestamp?: string;
+				model?: string;
+				toolName?: string;
+			}> = [];
+			let totalTokens = 0;
+			let totalCost = 0;
+
+			for (const entry of branchEntries) {
+				if (entry.type === "custom_message") {
+					const customEntry = entry as any;
+					const customContent = extractTextContent(customEntry.content);
+					if (!customContent.trim()) continue;
+					allMessages.push({
+						role: customEntry.customType ? `custom:${customEntry.customType}` : "custom",
+						content: customContent.trim(),
+						timestamp: customEntry.timestamp,
+					});
+					continue;
+				}
+
+				if (entry.type !== "message") continue;
+
+				const msg = entry.message;
+				const content = extractTextContent(msg.content);
+				if (!content.trim()) continue;
+
+				allMessages.push({
+					role: msg.role,
+					content: content.trim(),
+					timestamp: entry.timestamp,
+					model: "model" in msg ? (msg as any).model : undefined,
+					toolName: "toolName" in msg ? (msg as any).toolName : undefined,
+				});
+
+				if ("usage" in msg && msg.usage) {
+					const usage = msg.usage as { input?: number; output?: number; cost?: { total?: number } };
+					totalTokens += (usage.input || 0) + (usage.output || 0);
+					totalCost += usage.cost?.total || 0;
+				}
+			}
+
+			// Apply filtering
+			let filteredMessages = allMessages;
+			const originalCount = allMessages.length;
+
+			// 1. Filter by roles if specified
+			if (roles && roles.length > 0) {
+				const rolesLower = roles.map(r => r.toLowerCase());
+				filteredMessages = filteredMessages.filter(msg => 
+					rolesLower.includes(msg.role.toLowerCase())
+				);
+			}
+
+			// 2. Filter by query if specified, with context
+			let matchCount = 0;
+			if (query) {
+				const queryLower = query.toLowerCase();
+				const matchIndices = new Set<number>();
+				
+				// Find matching message indices
+				for (let i = 0; i < filteredMessages.length; i++) {
+					if (filteredMessages[i].content.toLowerCase().includes(queryLower)) {
+						matchCount++;
+						// Add the match and context
+						for (let j = Math.max(0, i - context); j <= Math.min(filteredMessages.length - 1, i + context); j++) {
+							matchIndices.add(j);
+						}
+					}
+				}
+				
+				// Keep only messages in the match set (preserving order)
+				filteredMessages = filteredMessages.filter((_, idx) => matchIndices.has(idx));
+			}
+
+			// 3. Apply max_messages limit (from end)
+			const limitedMessages = max_messages ? filteredMessages.slice(-max_messages) : filteredMessages;
+
+			// 4. Apply content length truncation
+			const finalMessages = max_content_length
+				? limitedMessages.map(msg => ({
+						...msg,
+						content: msg.content.length > max_content_length 
+							? msg.content.slice(0, max_content_length) + "..."
+							: msg.content,
+					}))
+				: limitedMessages;
+
+			const thread = {
+				id: header.id,
+				cwd: header.cwd || "",
+				timestamp: header.timestamp,
+				messages: finalMessages,
+				totalTokens,
+				totalCost,
+			};
+
+			// Format output
+			let text = `## Thread ${thread.id}\n`;
+			text += `**Directory:** ${thread.cwd}\n`;
+			text += `**Started:** ${new Date(thread.timestamp).toLocaleString()}\n`;
+			text += `**Messages:** ${originalCount} total | **Tokens:** ${totalTokens.toLocaleString()} | **Cost:** $${totalCost.toFixed(4)}\n`;
+			
+			// Show filtering info
+			const filters: string[] = [];
+			if (query) filters.push(`query="${query}" (${matchCount} matches)`);
+			if (roles) filters.push(`roles=[${roles.join(", ")}]`);
+			if (context > 0) filters.push(`context=${context}`);
+			if (max_messages) filters.push(`max_messages=${max_messages}`);
+			if (max_content_length) filters.push(`truncate=${max_content_length}`);
+			
+			if (filters.length > 0) {
+				text += `**Filters:** ${filters.join(" | ")}\n`;
+				text += `**Showing:** ${finalMessages.length} of ${originalCount} messages\n`;
+			}
+			
+			text += "\n---\n\n";
+
+			for (const msg of finalMessages) {
+				const roleIcon = msg.role === "user" ? "👤" : msg.role === "assistant" ? "🤖" : "🔧";
+				const roleLabel = msg.role === "toolResult" ? `tool:${msg.toolName}` : msg.role;
+				text += `### ${roleIcon} ${roleLabel}\n`;
+				if (msg.model) text += `*${msg.model}*\n`;
+				text += `\n${msg.content}\n\n`;
+			}
+
+			return {
+				content: [{ type: "text", text }],
+				details: { thread, matchCount, originalCount },
+			};
+		},
+
+		renderCall(args, theme) {
+			let text = theme.fg("toolTitle", theme.bold("search_thread"));
+			text += " " + theme.fg("accent", args.thread_id.slice(0, 36));
+			if (args.query) text += " " + theme.fg("warning", `"${args.query}"`);
+			if (args.roles) text += " " + theme.fg("dim", `roles:[${args.roles.join(",")}]`);
+			if (args.context) text += " " + theme.fg("dim", `ctx:${args.context}`);
+			if (args.max_messages) text += " " + theme.fg("dim", `last:${args.max_messages}`);
+			return new Text(text, 0, 0);
+		},
+
+		renderResult(result, { expanded }, theme) {
+			const { details } = result;
+			if (!details?.thread) {
+				const text = result.content[0];
+				return new Text(
+					text?.type === "text" ? theme.fg("error", text.text) : theme.fg("error", "(error)"),
+					0,
+					0,
+				);
+			}
+
+			const { thread, matchCount, originalCount } = details;
+			const icon = theme.fg("success", "✓");
+			const countInfo = matchCount !== undefined 
+				? `${matchCount} matches, ${thread.messages.length}/${originalCount} shown`
+				: `${thread.messages.length} messages`;
+
+			if (expanded) {
+				const container = new Container();
+				container.addChild(
+					new Text(`${icon} Thread ${theme.fg("accent", thread.id.slice(0, 8))} (${countInfo})`, 0, 0),
+				);
+				container.addChild(new Text(theme.fg("muted", `📁 ${thread.cwd}`), 0, 0));
+				container.addChild(
+					new Text(theme.fg("dim", `📊 ${thread.totalTokens.toLocaleString()} tokens | $${thread.totalCost.toFixed(4)}`), 0, 0),
+				);
+
+				for (const msg of thread.messages) {
+					container.addChild(new Spacer(1));
+					const roleIcon = msg.role === "user" ? "👤" : msg.role === "assistant" ? "🤖" : "🔧";
+					const preview = msg.content.length > 200 ? msg.content.slice(0, 200) + "..." : msg.content;
+					container.addChild(new Text(`${roleIcon} ${theme.fg("accent", msg.role)}`, 0, 0));
+					container.addChild(new Text(theme.fg("toolOutput", preview), 0, 0));
+				}
+				return container;
+			}
+
+			// Collapsed
+			let text = `${icon} Thread ${theme.fg("accent", thread.id.slice(0, 8))} (${countInfo})`;
+			text += `\n  ${theme.fg("muted", thread.cwd)}`;
+			for (const msg of thread.messages.slice(0, 3)) {
+				const preview = msg.content.slice(0, 60).replace(/\n/g, " ");
+				const roleIcon = msg.role === "user" ? "👤" : msg.role === "assistant" ? "🤖" : "🔧";
+				text += `\n  ${roleIcon} ${theme.fg("dim", preview)}${msg.content.length > 60 ? "..." : ""}`;
+			}
+			if (thread.messages.length > 3) {
+				text += `\n  ${theme.fg("muted", `... +${thread.messages.length - 3} more (Ctrl+O to expand)`)}`;
+			}
+			return new Text(text, 0, 0);
+		},
+	});
+}
dots/pi/agent/extensions/threads/package.json
@@ -0,0 +1,22 @@
+{
+  "name": "pi-threads",
+  "version": "0.2.1",
+  "description": "Pi extension for searching and reading past conversation threads with ripgrep-powered search",
+  "type": "module",
+  "keywords": ["pi-package"],
+  "license": "MIT",
+  "author": "laulauland",
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/laulauland/dotfiles"
+  },
+  "pi": {
+    "extensions": ["./index.ts"]
+  },
+  "peerDependencies": {
+    "@mariozechner/pi-ai": "*",
+    "@mariozechner/pi-coding-agent": "*",
+    "@mariozechner/pi-tui": "*",
+    "@sinclair/typebox": "*"
+  }
+}
dots/pi/agent/extensions/threads/README.md
@@ -0,0 +1,66 @@
+# pi-threads
+
+A [pi](https://github.com/badlogic/pi-mono) extension that provides tools for searching and reading past conversation sessions.
+
+## Features
+
+- **`find_threads` tool**: Search through past conversations using ripgrep for sub-20ms search performance
+- **`read_thread` tool**: Read a specific conversation thread by ID or file path
+- **Custom rendering**: Compact and expanded views with proper theming
+- **Lazy parsing**: Only parse files that match search criteria
+
+## Installation
+
+```bash
+pi install npm:pi-threads
+```
+
+Or add to your `~/.pi/agent/settings.json`:
+
+```json
+{
+  "packages": ["npm:pi-threads"]
+}
+```
+
+## Tools
+
+### find_threads
+
+Search through past conversation sessions.
+
+**Parameters:**
+- `query` (optional): Text to search for in messages (uses ripgrep)
+- `cwd` (optional): Filter by working directory (partial match)
+- `limit` (optional): Maximum results to return (default: 10)
+- `sort` (optional): Sort order - "recent" (default), "oldest", or "relevance"
+
+**Example usage by LLM:**
+```
+Find threads about "authentication" in the auth-service project
+```
+
+### read_thread
+
+Read a specific conversation thread by ID or file path.
+
+**Parameters:**
+- `thread_id`: Thread ID (session UUID) or file path
+- `include_tool_results` (optional): Include tool call results in output (default: false)
+- `max_messages` (optional): Maximum messages to return (default: all)
+
+**Example usage by LLM:**
+```
+Read thread abc123-def456 to see the authentication implementation discussion
+```
+
+## Use Cases
+
+- **Context retrieval**: "What did we decide about the API design last week?"
+- **Code archaeology**: "Find the thread where we implemented the caching layer"
+- **Continuation**: "What was the status of the refactoring work?"
+- **Knowledge transfer**: "Show me conversations about the payment integration"
+
+## License
+
+MIT
dots/pi/agent/extensions/file-picker.ts
@@ -0,0 +1,1059 @@
+/**
+ * File Picker Extension
+ *
+ * Replaces the built-in @ file picker with an enhanced file browser.
+ * Selected files are attached to the prompt as context.
+ *
+ * Features:
+ * - @ shortcut opens file browser (replaces built-in)
+ * - Directory navigation with Enter
+ * - Space to toggle selection
+ * - Tab to toggle options panel (gitignore, hidden files)
+ * - Fuzzy search and glob patterns
+ * - Git-aware file listing (respects .gitignore)
+ * - Selected files injected as context on prompt submit
+ *
+ * Based on codemap extension by @kcosr
+ */
+
+import {
+	CustomEditor,
+	type ExtensionAPI,
+	type ExtensionContext,
+} from "@mariozechner/pi-coding-agent";
+import { matchesKey, visibleWidth, type EditorTheme, type TUI } from "@mariozechner/pi-tui";
+import * as fs from "node:fs";
+import * as path from "node:path";
+import * as os from "node:os";
+import { execSync } from "node:child_process";
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Types
+// ═══════════════════════════════════════════════════════════════════════════
+
+interface FileEntry {
+	name: string;
+	isDirectory: boolean;
+	relativePath: string;
+}
+
+interface PickerState {
+	respectGitignore: boolean;
+	skipHidden: boolean;
+}
+
+interface PickerConfig {
+	respectGitignore?: boolean;
+	skipHidden?: boolean;
+	skipPatterns?: string[];
+}
+
+interface BrowserOption {
+	id: string;
+	label: string;
+	enabled: boolean;
+	visible: () => boolean;
+}
+
+interface SelectedPath {
+	path: string;
+	isDirectory: boolean;
+}
+
+type FileBrowserAction = 
+	| { action: "confirm"; paths: SelectedPath[] }
+	| { action: "cancel" }
+	| { action: "select"; selected: SelectedPath; paths: SelectedPath[] };
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Config
+// ═══════════════════════════════════════════════════════════════════════════
+
+const DEFAULT_CONFIG: PickerConfig = {
+	respectGitignore: true,
+	skipHidden: true,
+	skipPatterns: ["node_modules"],
+};
+
+function loadConfig(): PickerConfig {
+	const configPath = path.join(
+		os.homedir(),
+		".pi",
+		"agent",
+		"extensions",
+		"file-picker",
+		"config.json"
+	);
+	try {
+		if (fs.existsSync(configPath)) {
+			const content = fs.readFileSync(configPath, "utf-8");
+			const custom = JSON.parse(content) as Partial<PickerConfig>;
+			return { ...DEFAULT_CONFIG, ...custom };
+		}
+	} catch {
+		// Ignore errors, use default
+	}
+	return DEFAULT_CONFIG;
+}
+
+const config = loadConfig();
+const skipPatterns = config.skipPatterns ?? ["node_modules"];
+
+// ═══════════════════════════════════════════════════════════════════════════
+// State (per-session, reset on session switch)
+// ═══════════════════════════════════════════════════════════════════════════
+
+const state: PickerState = {
+	respectGitignore: config.respectGitignore ?? true,
+	skipHidden: config.skipHidden ?? true,
+};
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Theming
+// ═══════════════════════════════════════════════════════════════════════════
+
+interface PaletteTheme {
+	border: string;
+	title: string;
+	selected: string;
+	selectedText: string;
+	directory: string;
+	checked: string;
+	searchIcon: string;
+	placeholder: string;
+	hint: string;
+}
+
+const DEFAULT_THEME: PaletteTheme = {
+	border: "2",
+	title: "2",
+	selected: "36",
+	selectedText: "36",
+	directory: "34",
+	checked: "32",
+	searchIcon: "2",
+	placeholder: "2;3",
+	hint: "2",
+};
+
+function loadTheme(): PaletteTheme {
+	const themePath = path.join(
+		os.homedir(),
+		".pi",
+		"agent",
+		"extensions",
+		"file-picker",
+		"theme.json"
+	);
+	try {
+		if (fs.existsSync(themePath)) {
+			const content = fs.readFileSync(themePath, "utf-8");
+			const custom = JSON.parse(content) as Partial<PaletteTheme>;
+			return { ...DEFAULT_THEME, ...custom };
+		}
+	} catch {
+		// Ignore errors
+	}
+	return DEFAULT_THEME;
+}
+
+function fg(code: string, text: string): string {
+	if (!code) return text;
+	return `\x1b[${code}m${text}\x1b[0m`;
+}
+
+const paletteTheme = loadTheme();
+
+// ═══════════════════════════════════════════════════════════════════════════
+// File System Utilities
+// ═══════════════════════════════════════════════════════════════════════════
+
+function getCwdRoot(): string {
+	return process.cwd();
+}
+
+function isWithinCwd(targetPath: string, cwdRoot: string): boolean {
+	const resolved = path.resolve(targetPath);
+	const normalizedCwd = path.resolve(cwdRoot);
+	return (
+		resolved === normalizedCwd ||
+		resolved.startsWith(normalizedCwd + path.sep)
+	);
+}
+
+function shouldSkipPattern(name: string): boolean {
+	return skipPatterns.some((pattern) => {
+		if (pattern.includes("*")) {
+			const regex = globToRegex(pattern);
+			return regex.test(name);
+		}
+		return name === pattern;
+	});
+}
+
+function listDirectoryWithGit(
+	dirPath: string,
+	cwdRoot: string,
+	gitFiles: Set<string> | null,
+	skipHidden: boolean
+): FileEntry[] {
+	const entries: FileEntry[] = [];
+
+	try {
+		const items = fs.readdirSync(dirPath, { withFileTypes: true });
+		const relDir = path.relative(cwdRoot, dirPath);
+
+		for (const item of items) {
+			if (skipHidden && item.name.startsWith(".")) continue;
+			if (shouldSkipPattern(item.name)) continue;
+
+			const fullPath = path.join(dirPath, item.name);
+			const relativePath = relDir ? path.join(relDir, item.name) : item.name;
+
+			let isDirectory = item.isDirectory();
+			if (item.isSymbolicLink()) {
+				try {
+					const stats = fs.statSync(fullPath);
+					isDirectory = stats.isDirectory();
+				} catch {
+					continue;
+				}
+			}
+
+			if (gitFiles !== null) {
+				if (isDirectory) {
+					let hasGitFiles = false;
+					const prefix = relativePath + "/";
+					for (const gitFile of gitFiles) {
+						if (gitFile.startsWith(prefix) || gitFile === relativePath) {
+							hasGitFiles = true;
+							break;
+						}
+					}
+					if (!hasGitFiles) continue;
+				} else {
+					if (!gitFiles.has(relativePath)) continue;
+				}
+			}
+
+			entries.push({
+				name: item.name,
+				isDirectory,
+				relativePath,
+			});
+		}
+
+		entries.sort((a, b) => {
+			if (a.isDirectory && !b.isDirectory) return -1;
+			if (!a.isDirectory && b.isDirectory) return 1;
+			return a.name.localeCompare(b.name);
+		});
+	} catch {
+		// Return empty on error
+	}
+
+	return entries;
+}
+
+function listAllFiles(
+	dirPath: string,
+	cwdRoot: string,
+	results: FileEntry[],
+	skipHidden: boolean
+): FileEntry[] {
+	try {
+		const items = fs.readdirSync(dirPath, { withFileTypes: true });
+
+		for (const item of items) {
+			if (skipHidden && item.name.startsWith(".")) continue;
+			if (shouldSkipPattern(item.name)) continue;
+
+			const fullPath = path.join(dirPath, item.name);
+			const relativePath = path.relative(cwdRoot, fullPath);
+
+			let isDirectory = item.isDirectory();
+			if (item.isSymbolicLink()) {
+				try {
+					const stats = fs.statSync(fullPath);
+					isDirectory = stats.isDirectory();
+				} catch {
+					continue;
+				}
+			}
+
+			results.push({
+				name: item.name,
+				isDirectory,
+				relativePath,
+			});
+
+			if (isDirectory) {
+				listAllFiles(fullPath, cwdRoot, results, skipHidden);
+			}
+		}
+	} catch {
+		// Skip inaccessible directories
+	}
+
+	return results;
+}
+
+function isGitRepo(cwdRoot: string): boolean {
+	try {
+		execSync("git rev-parse --is-inside-work-tree", {
+			cwd: cwdRoot,
+			encoding: "utf-8",
+			stdio: "pipe",
+		});
+		return true;
+	} catch {
+		return false;
+	}
+}
+
+function listGitFiles(cwdRoot: string): FileEntry[] {
+	const entries: FileEntry[] = [];
+
+	try {
+		const output = execSync(
+			"git ls-files --cached --others --exclude-standard",
+			{
+				cwd: cwdRoot,
+				encoding: "utf-8",
+				stdio: "pipe",
+				maxBuffer: 10 * 1024 * 1024,
+			}
+		);
+
+		const files = output
+			.trim()
+			.split("\n")
+			.filter((f) => f);
+
+		for (const relativePath of files) {
+			const fullPath = path.join(cwdRoot, relativePath);
+			const name = path.basename(relativePath);
+
+			let isDirectory = false;
+			try {
+				const stats = fs.statSync(fullPath);
+				isDirectory = stats.isDirectory();
+			} catch {
+				continue;
+			}
+
+			entries.push({
+				name,
+				isDirectory,
+				relativePath,
+			});
+		}
+
+		const dirs = new Set<string>();
+		for (const entry of entries) {
+			let dir = path.dirname(entry.relativePath);
+			while (dir && dir !== ".") {
+				dirs.add(dir);
+				dir = path.dirname(dir);
+			}
+		}
+
+		for (const dir of dirs) {
+			entries.push({
+				name: path.basename(dir),
+				isDirectory: true,
+				relativePath: dir,
+			});
+		}
+	} catch {
+		// Fall back to empty
+	}
+
+	return entries;
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Search Utilities
+// ═══════════════════════════════════════════════════════════════════════════
+
+function isGlobPattern(query: string): boolean {
+	return /[*?[\]]/.test(query);
+}
+
+function globToRegex(pattern: string): RegExp {
+	let regex = "";
+	let i = 0;
+
+	while (i < pattern.length) {
+		const char = pattern[i];
+
+		if (char === "*") {
+			if (pattern[i + 1] === "*") {
+				regex += ".*";
+				i += 2;
+				if (pattern[i] === "/") i++;
+			} else {
+				regex += "[^/]*";
+				i++;
+			}
+		} else if (char === "?") {
+			regex += "[^/]";
+			i++;
+		} else if (char === "[") {
+			const end = pattern.indexOf("]", i);
+			if (end !== -1) {
+				regex += pattern.slice(i, end + 1);
+				i = end + 1;
+			} else {
+				regex += "\\[";
+				i++;
+			}
+		} else if (".+^${}()|\\".includes(char)) {
+			regex += "\\" + char;
+			i++;
+		} else {
+			regex += char;
+			i++;
+		}
+	}
+
+	return new RegExp("^" + regex + "$", "i");
+}
+
+function fuzzyScore(query: string, text: string): number {
+	const lowerQuery = query.toLowerCase();
+	const lowerText = text.toLowerCase();
+
+	if (lowerText.includes(lowerQuery)) {
+		return 100 + (lowerQuery.length / lowerText.length) * 50;
+	}
+
+	let score = 0;
+	let queryIndex = 0;
+	let consecutiveBonus = 0;
+
+	for (let i = 0; i < lowerText.length && queryIndex < lowerQuery.length; i++) {
+		if (lowerText[i] === lowerQuery[queryIndex]) {
+			score += 10 + consecutiveBonus;
+			consecutiveBonus += 5;
+			queryIndex++;
+		} else {
+			consecutiveBonus = 0;
+		}
+	}
+
+	return queryIndex === lowerQuery.length ? score : 0;
+}
+
+function filterEntries(entries: FileEntry[], query: string): FileEntry[] {
+	if (!query.trim()) return entries;
+
+	if (isGlobPattern(query)) {
+		const regex = globToRegex(query);
+		const filtered = entries.filter(
+			(entry) => regex.test(entry.name) || regex.test(entry.relativePath)
+		);
+		// Sort: files first, then directories
+		return filtered.sort((a, b) => {
+			if (a.isDirectory && !b.isDirectory) return 1;
+			if (!a.isDirectory && b.isDirectory) return -1;
+			return a.relativePath.localeCompare(b.relativePath);
+		});
+	}
+
+	const scored = entries
+		.map((entry) => ({
+			entry,
+			score: Math.max(
+				fuzzyScore(query, entry.name),
+				fuzzyScore(query, entry.relativePath) * 0.9
+			),
+		}))
+		.filter((item) => item.score > 0)
+		.sort((a, b) => {
+			// Primary: files before directories
+			if (a.entry.isDirectory && !b.entry.isDirectory) return 1;
+			if (!a.entry.isDirectory && b.entry.isDirectory) return -1;
+			// Secondary: by score
+			return b.score - a.score;
+		});
+
+	return scored.map((item) => item.entry);
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// File Browser Component
+// ═══════════════════════════════════════════════════════════════════════════
+
+class FileBrowserComponent {
+	readonly width = 100;
+	private readonly maxVisible = 10;
+	private cwdRoot: string;
+	private currentDir: string;
+	private allEntries: FileEntry[];
+	private allFilesRecursive: FileEntry[];
+	private filtered: FileEntry[];
+	private selected = 0;
+	private query = "";
+	private isSearchMode = false;
+	private selectedPaths: Map<string, boolean>; // path -> isDirectory
+	private rootParentView = false;
+	private inGitRepo: boolean;
+	private gitFiles: Set<string> | null = null;
+	private focusOnOptions = false;
+	private selectedOption = 0;
+	private options: BrowserOption[];
+	private done: (action: FileBrowserAction) => void;
+
+	constructor(done: (action: FileBrowserAction) => void) {
+		this.done = done;
+		this.cwdRoot = getCwdRoot();
+		this.currentDir = this.cwdRoot;
+		this.selectedPaths = new Map();
+		this.inGitRepo = isGitRepo(this.cwdRoot);
+
+		this.options = [
+			{
+				id: "gitignore",
+				label: "Respect .gitignore",
+				enabled: state.respectGitignore,
+				visible: () => this.inGitRepo,
+			},
+			{
+				id: "skipHidden",
+				label: "Skip hidden files",
+				enabled: state.skipHidden,
+				visible: () => true,
+			},
+		];
+
+		this.rebuildFileLists();
+	}
+
+	private getOption(id: string): BrowserOption | undefined {
+		return this.options.find((o) => o.id === id);
+	}
+
+	private getVisibleOptions(): BrowserOption[] {
+		return this.options.filter((o) => o.visible());
+	}
+
+	private rebuildFileLists(): void {
+		const respectGitignore = this.getOption("gitignore")?.enabled ?? false;
+		const skipHidden = this.getOption("skipHidden")?.enabled ?? true;
+
+		if (this.inGitRepo && respectGitignore) {
+			const gitEntries = listGitFiles(this.cwdRoot);
+			this.gitFiles = new Set(gitEntries.map((e) => e.relativePath));
+			this.allFilesRecursive = gitEntries;
+		} else {
+			this.gitFiles = null;
+			this.allFilesRecursive = listAllFiles(this.cwdRoot, this.cwdRoot, [], skipHidden);
+		}
+
+		this.allEntries = this.listCurrentDirectory();
+		this.updateFilter();
+	}
+
+	private listCurrentDirectory(): FileEntry[] {
+		if (this.rootParentView) {
+			return [{
+				name: path.basename(this.cwdRoot),
+				isDirectory: true,
+				relativePath: ".",
+			}];
+		}
+
+		const skipHidden = this.getOption("skipHidden")?.enabled ?? true;
+		const entries = listDirectoryWithGit(
+			this.currentDir,
+			this.cwdRoot,
+			this.gitFiles,
+			skipHidden
+		);
+
+		entries.unshift({
+			name: "..",
+			isDirectory: true,
+			relativePath: "..",
+		});
+
+		return entries;
+	}
+
+	private isUpEntry(entry: FileEntry): boolean {
+		return entry.name === ".." && entry.relativePath === "..";
+	}
+
+	private navigateTo(dir: string): void {
+		if (!isWithinCwd(dir, this.cwdRoot)) return;
+
+		this.rootParentView = false;
+		this.currentDir = dir;
+		this.allEntries = this.listCurrentDirectory();
+		this.query = "";
+		this.isSearchMode = false;
+		this.filtered = this.allEntries;
+		this.selected = 0;
+	}
+
+	private goUp(): boolean {
+		if (this.rootParentView) return false;
+
+		if (this.currentDir === this.cwdRoot) {
+			this.rootParentView = true;
+			this.allEntries = this.listCurrentDirectory();
+			this.query = "";
+			this.isSearchMode = false;
+			this.filtered = this.allEntries;
+			this.selected = 0;
+			return true;
+		}
+
+		const parentDir = path.dirname(this.currentDir);
+		if (isWithinCwd(parentDir, this.cwdRoot)) {
+			this.navigateTo(parentDir);
+			return true;
+		}
+		return false;
+	}
+
+	handleInput(data: string): void {
+		if (matchesKey(data, "tab")) {
+			const visibleOptions = this.getVisibleOptions();
+			if (visibleOptions.length > 0) {
+				this.focusOnOptions = !this.focusOnOptions;
+				if (this.focusOnOptions) this.selectedOption = 0;
+			}
+			return;
+		}
+
+		if (this.focusOnOptions) {
+			this.handleOptionsInput(data);
+		} else {
+			this.handleBrowserInput(data);
+		}
+	}
+
+	private handleOptionsInput(data: string): void {
+		const visibleOptions = this.getVisibleOptions();
+		const currentOption = visibleOptions[this.selectedOption];
+
+		if (matchesKey(data, "escape")) {
+			this.focusOnOptions = false;
+			return;
+		}
+
+		if (matchesKey(data, "up")) {
+			if (visibleOptions.length > 0) {
+				this.selectedOption = this.selectedOption === 0
+					? visibleOptions.length - 1
+					: this.selectedOption - 1;
+			}
+			return;
+		}
+
+		if (matchesKey(data, "down")) {
+			if (visibleOptions.length > 0) {
+				this.selectedOption = this.selectedOption === visibleOptions.length - 1
+					? 0
+					: this.selectedOption + 1;
+			}
+			return;
+		}
+
+		if (data === " " || matchesKey(data, "return")) {
+			if (currentOption) {
+				currentOption.enabled = !currentOption.enabled;
+				// Sync to global state
+				if (currentOption.id === "gitignore") {
+					state.respectGitignore = currentOption.enabled;
+				} else if (currentOption.id === "skipHidden") {
+					state.skipHidden = currentOption.enabled;
+				}
+				this.rebuildFileLists();
+			}
+		}
+	}
+
+	private getSelectedPathsArray(): SelectedPath[] {
+		return Array.from(this.selectedPaths.entries()).map(([p, isDir]) => ({ path: p, isDirectory: isDir }));
+	}
+
+	private handleBrowserInput(data: string): void {
+		if (matchesKey(data, "escape")) {
+			if (!this.goUp()) {
+				this.done({ action: "confirm", paths: this.getSelectedPathsArray() });
+			}
+			return;
+		}
+
+		// Enter = select and insert (files or directories)
+		if (matchesKey(data, "return")) {
+			const entry = this.filtered[this.selected];
+			if (entry) {
+				if (entry.name === "..") {
+					this.goUp();
+				} else {
+					// Select and close (works for both files and directories)
+					const paths = this.getSelectedPathsArray();
+					const selected = { path: entry.relativePath, isDirectory: entry.isDirectory };
+					if (!this.selectedPaths.has(entry.relativePath)) {
+						paths.push(selected);
+					}
+					this.done({ action: "select", selected, paths });
+				}
+			}
+			return;
+		}
+
+		// Space or Right arrow = navigate directories, toggle files
+		if (data === " " || matchesKey(data, "right")) {
+			const entry = this.filtered[this.selected];
+			if (entry && !this.isUpEntry(entry)) {
+				if (entry.isDirectory) {
+					// Navigate into directory
+					this.navigateTo(path.join(this.cwdRoot, entry.relativePath));
+				} else {
+					// Toggle selection for multi-select (files only)
+					if (this.selectedPaths.has(entry.relativePath)) {
+						this.selectedPaths.delete(entry.relativePath);
+					} else {
+						this.selectedPaths.set(entry.relativePath, entry.isDirectory);
+					}
+				}
+			}
+			return;
+		}
+
+		if (matchesKey(data, "up")) {
+			if (this.filtered.length > 0) {
+				this.selected = this.selected === 0 ? this.filtered.length - 1 : this.selected - 1;
+			}
+			return;
+		}
+
+		if (matchesKey(data, "down")) {
+			if (this.filtered.length > 0) {
+				this.selected = this.selected === this.filtered.length - 1 ? 0 : this.selected + 1;
+			}
+			return;
+		}
+
+		// Left arrow = go up directory
+		if (matchesKey(data, "left")) {
+			this.goUp();
+			return;
+		}
+
+		if (matchesKey(data, "backspace")) {
+			if (this.query.length > 0) {
+				this.query = this.query.slice(0, -1);
+				this.updateFilter();
+			} else {
+				this.goUp();
+			}
+			return;
+		}
+
+		if (data.length === 1 && data.charCodeAt(0) >= 32) {
+			this.query += data;
+			this.updateFilter();
+		}
+	}
+
+	private updateFilter(): void {
+		if (this.query.trim()) {
+			this.isSearchMode = true;
+			this.filtered = filterEntries(this.allFilesRecursive, this.query);
+		} else {
+			this.isSearchMode = false;
+			this.filtered = this.allEntries;
+		}
+		this.selected = 0;
+	}
+
+	render(_width: number): string[] {
+		const w = this.width;
+		const innerW = w - 2;
+		const lines: string[] = [];
+
+		const t = paletteTheme;
+		const border = (s: string) => fg(t.border, s);
+		const title = (s: string) => fg(t.title, s);
+		const selected = (s: string) => fg(t.selected, s);
+		const selectedText = (s: string) => fg(t.selectedText, s);
+		const directory = (s: string) => fg(t.directory, s);
+		const checked = (s: string) => fg(t.checked, s);
+		const hint = (s: string) => fg(t.hint, s);
+		const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
+
+		const pad = (s: string, len: number) => s + " ".repeat(Math.max(0, len - visibleWidth(s)));
+
+		const truncate = (s: string, maxW: number) => {
+			if (visibleWidth(s) <= maxW) return s;
+			let result = "";
+			let width = 0;
+			for (const char of s) {
+				const charWidth = visibleWidth(char);
+				if (width + charWidth > maxW - 1) break;
+				result += char;
+				width += charWidth;
+			}
+			return result + "…";
+		};
+
+		const row = (content: string) => border("│") + pad(content, innerW) + border("│");
+
+		// Top border with title
+		let titleText: string;
+		if (this.isSearchMode) {
+			titleText = " Search ";
+		} else if (this.rootParentView) {
+			titleText = " Files ";
+		} else {
+			const relDir = path.relative(this.cwdRoot, this.currentDir);
+			titleText = relDir ? ` ${truncate(relDir, 40)} ` : " Files ";
+		}
+		const borderLen = Math.max(0, innerW - visibleWidth(titleText));
+		const leftBorder = Math.floor(borderLen / 2);
+		const rightBorder = borderLen - leftBorder;
+		lines.push(
+			border("╭" + "─".repeat(leftBorder)) +
+				title(titleText) +
+				border("─".repeat(rightBorder) + "╮")
+		);
+
+		// Search input
+		const searchPrompt = selected("❯ ");
+		const queryDisplay = this.query || hint("Search files...");
+		const modeIndicator = this.query && isGlobPattern(this.query) ? hint(" [glob]") : "";
+		lines.push(row(` ${searchPrompt}${queryDisplay}${modeIndicator}`));
+
+		// Options row
+		const visibleOptions = this.getVisibleOptions();
+		if (visibleOptions.length > 0) {
+			const optParts: string[] = [];
+			for (let i = 0; i < visibleOptions.length; i++) {
+				const opt = visibleOptions[i];
+				const isSelectedOpt = this.focusOnOptions && i === this.selectedOption;
+				const checkbox = opt.enabled ? checked("☑") : hint("☐");
+				const label = isSelectedOpt
+					? selected(opt.label)
+					: opt.enabled
+						? opt.label
+						: hint(opt.label);
+				const prefix = isSelectedOpt ? selected("▸") : " ";
+				optParts.push(`${prefix}${checkbox} ${label}`);
+			}
+			const optionsStr = optParts.join(" ");
+			const tabHint = this.focusOnOptions ? hint(" (space toggle, esc exit)") : hint(" (tab)");
+			lines.push(row(` ${optionsStr}${tabHint}`));
+		} else {
+			lines.push(row(""));
+		}
+
+		// Divider
+		lines.push(border(`├${"─".repeat(innerW)}┤`));
+
+		// File list - always render exactly maxVisible rows
+		const startIndex = Math.max(
+			0,
+			Math.min(this.selected - Math.floor(this.maxVisible / 2), this.filtered.length - this.maxVisible)
+		);
+
+		for (let i = 0; i < this.maxVisible; i++) {
+			const actualIndex = startIndex + i;
+			if (actualIndex < this.filtered.length) {
+				const entry = this.filtered[actualIndex];
+				const isSelectedEntry = actualIndex === this.selected;
+				const isUpDir = this.isUpEntry(entry);
+				const isChecked = !isUpDir && this.selectedPaths.has(entry.relativePath);
+
+				const prefix = isSelectedEntry ? selected(" ▶ ") : "   ";
+
+				let displayName: string;
+				if (isUpDir) {
+					displayName = "..";
+				} else if (this.isSearchMode) {
+					displayName = entry.relativePath + (entry.isDirectory ? "/" : "");
+				} else {
+					displayName = entry.name + (entry.isDirectory ? "/" : "");
+				}
+
+				const maxNameLen = innerW - 8;
+				const truncatedName = truncate(displayName, maxNameLen);
+
+				let nameStr: string;
+				if (isUpDir) {
+					nameStr = isSelectedEntry ? bold(selectedText(truncatedName)) : hint(truncatedName);
+				} else if (entry.isDirectory) {
+					nameStr = isSelectedEntry ? bold(selectedText(truncatedName)) : directory(truncatedName);
+				} else {
+					nameStr = isSelectedEntry ? bold(selectedText(truncatedName)) : truncatedName;
+				}
+
+				if (isUpDir) {
+					lines.push(row(`${prefix}   ${nameStr}`));
+				} else {
+					const checkMark = isChecked ? checked("☑ ") : hint("☐ ");
+					lines.push(row(`${prefix}${checkMark}${nameStr}`));
+				}
+			} else if (i === 0 && this.filtered.length === 0) {
+				lines.push(row(hint("   No matching files")));
+			} else {
+				lines.push(row(""));
+			}
+		}
+
+		// Scroll/count indicator row
+		if (this.filtered.length > this.maxVisible) {
+			const shown = `${startIndex + 1}-${Math.min(startIndex + this.maxVisible, this.filtered.length)}`;
+			lines.push(row(hint(` (${shown} of ${this.filtered.length})`)));
+		} else if (this.filtered.length > 0) {
+			lines.push(row(hint(` (${this.filtered.length} file${this.filtered.length === 1 ? "" : "s"})`)));
+		} else {
+			lines.push(row(""));
+		}
+
+		// Selection summary section
+		lines.push(border(`├${"─".repeat(innerW)}┤`));
+		if (this.selectedPaths.size > 0) {
+			const selectedList = Array.from(this.selectedPaths.keys()).slice(0, 3);
+			const preview = selectedList.join(", ") + (this.selectedPaths.size > 3 ? ", ..." : "");
+			lines.push(row(` ${checked(`Selected (${this.selectedPaths.size}):`)} ${truncate(preview, innerW - 18)}`));
+		} else {
+			lines.push(row(hint(" No files selected")));
+		}
+
+		// Footer
+		lines.push(border(`├${"─".repeat(innerW)}┤`));
+		lines.push(row(hint(" ↑↓ navigate  ←→ dirs  space toggle  enter select  esc done")));
+
+		// Bottom border
+		lines.push(border(`╰${"─".repeat(innerW)}╯`));
+
+		return lines;
+	}
+
+	invalidate(): void {}
+	dispose(): void {}
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Shared File Picker Logic
+// ═══════════════════════════════════════════════════════════════════════════
+
+async function openFilePicker(ui: ExtensionContext["ui"]): Promise<void> {
+	const result = await ui.custom<FileBrowserAction>(
+		(_tui, _theme, _kb, done) => new FileBrowserComponent(done),
+		{ overlay: true }
+	);
+
+	const paths = result.action === "cancel" ? [] : result.paths;
+	if (paths.length > 0) {
+		// Add trailing / for directories to make it clear
+		const refs = paths.map(p => `@${p.path}${p.isDirectory ? "/" : ""}`).join(" ");
+		const currentText = ui.getEditorText();
+		ui.setEditorText(currentText + refs + " ");
+		ui.notify(`Added ${paths.length} file${paths.length > 1 ? "s" : ""}`, "info");
+	}
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Custom Editor (intercepts @)
+// ═══════════════════════════════════════════════════════════════════════════
+
+class FilePickerEditor extends CustomEditor {
+	private opening = false;
+	private originalProvider: any = null;
+
+	constructor(
+		private tui: TUI,
+		theme: EditorTheme,
+		keybindings: any,
+		private ui: ExtensionContext["ui"]
+	) {
+		super(tui, theme, keybindings);
+	}
+
+	// Wrap the autocomplete provider to filter out @ completions
+	setAutocompleteProvider(provider: any): void {
+		this.originalProvider = provider;
+		
+		const wrappedProvider = {
+			getSuggestions: (lines: string[], cursorLine: number, cursorCol: number) => {
+				const line = lines[cursorLine] ?? "";
+				const beforeCursor = line.slice(0, cursorCol);
+				
+				// If in @ context, return null (no suggestions) - we handle @ ourselves
+				if (beforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
+					return null;
+				}
+				
+				return provider.getSuggestions(lines, cursorLine, cursorCol);
+			},
+			applyCompletion: (...args: any[]) => provider.applyCompletion(...args),
+		};
+		
+		super.setAutocompleteProvider(wrappedProvider);
+	}
+
+	handleInput(data: string): void {
+		if (this.opening) return;
+
+		// Intercept @ to open our file picker instead of native
+		if (data === "@" && this.shouldTriggerFilePicker()) {
+			this.opening = true;
+			if (this.isShowingAutocomplete()) {
+				super.handleInput("\x1b");
+			}
+			openFilePicker(this.ui).finally(() => { this.opening = false; });
+			return;
+		}
+
+		super.handleInput(data);
+	}
+
+	private shouldTriggerFilePicker(): boolean {
+		const cursor = this.getCursor();
+		const line = this.getLines()[cursor.line] ?? "";
+
+		if (cursor.col === 0) return true;
+
+		const before = line[cursor.col - 1];
+		return before === " " || before === "\t" || before === undefined;
+	}
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Extension Export
+// ═══════════════════════════════════════════════════════════════════════════
+
+export default function (pi: ExtensionAPI) {
+	const attachEditor = (ctx: ExtensionContext) => {
+		if (!ctx.hasUI) return;
+		ctx.ui.setEditorComponent(
+			(tui, theme, keybindings) => new FilePickerEditor(tui, theme, keybindings, ctx.ui)
+		);
+	};
+
+	pi.on("session_start", (_event, ctx) => {
+		state.respectGitignore = config.respectGitignore ?? true;
+		state.skipHidden = config.skipHidden ?? true;
+		attachEditor(ctx);
+	});
+
+	pi.on("session_switch", (_event, ctx) => attachEditor(ctx));
+
+	pi.registerCommand("files", {
+		description: "Browse and select files to reference",
+		handler: async (_args, ctx) => {
+			if (!ctx.hasUI) {
+				ctx.ui.notify("File picker requires interactive mode", "error");
+				return;
+			}
+			await openFilePicker(ctx.ui);
+		},
+	});
+}
dots/pi/agent/extensions/orc-mode.ts
@@ -0,0 +1,210 @@
+/**
+ * Orchestrator Mode Extension
+ *
+ * A simple mode that transforms the main agent into an orchestrator.
+ * When enabled, only the subagent tool is available and the system prompt
+ * is updated to focus on coordination and delegation.
+ *
+ * Features:
+ * - /orc command to toggle orchestrator mode
+ * - In orc mode: only subagent tool available
+ * - System prompt modified to focus on orchestration
+ * - Orange status indicator when active
+ *
+ * Usage:
+ * 1. Use /orc to toggle orchestrator mode on/off
+ * 2. Or start with --orc flag
+ */
+
+import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
+
+// Tools available in orchestrator mode - subagent for delegation, subagent_status for async monitoring
+const ORC_MODE_TOOLS = ["subagent", "subagent_status"];
+
+// Store the original tools to restore them when disabling orc mode
+let savedTools: string[] | null = null;
+
+export default function orcModeExtension(pi: ExtensionAPI) {
+	let orcModeEnabled = false;
+
+	// Register --orc CLI flag
+	pi.registerFlag("orc", {
+		description: "Start in orchestrator mode (coordinator-only)",
+		type: "boolean",
+		default: false,
+	});
+
+	// Helper to update status displays
+	function updateStatus(ctx: ExtensionContext) {
+		if (orcModeEnabled) {
+			ctx.ui.setStatus("orc-mode", ctx.ui.theme.fg("warning", "🎭 ORC"));
+			ctx.ui.setWidget("orc-mode", [
+				ctx.ui.theme.fg("warning", "🎭 Orchestrator mode active"),
+				ctx.ui.theme.fg("muted", "Only subagent tool available"),
+				ctx.ui.theme.fg("dim", "Use /orc to disable"),
+			]);
+		} else {
+			ctx.ui.setStatus("orc-mode", undefined);
+			ctx.ui.setWidget("orc-mode", undefined);
+		}
+	}
+
+	function enableOrcMode(ctx: ExtensionContext) {
+		if (orcModeEnabled) return;
+
+		// Save current tools before switching
+		savedTools = pi.getActiveTools();
+		orcModeEnabled = true;
+		pi.setActiveTools(ORC_MODE_TOOLS);
+		ctx.ui.notify("🎭 Orchestrator mode enabled. Only subagent tool available.");
+		updateStatus(ctx);
+		persistState();
+	}
+
+	function disableOrcMode(ctx: ExtensionContext) {
+		if (!orcModeEnabled) return;
+
+		orcModeEnabled = false;
+		// Restore original tools or use defaults
+		if (savedTools && savedTools.length > 0) {
+			pi.setActiveTools(savedTools);
+		} else {
+			// Fallback to standard tools
+			pi.setActiveTools(["read", "bash", "edit", "write", "subagent"]);
+		}
+		savedTools = null;
+		ctx.ui.notify("Orchestrator mode disabled. Full tool access restored.");
+		updateStatus(ctx);
+		persistState();
+	}
+
+	function toggleOrcMode(ctx: ExtensionContext) {
+		if (orcModeEnabled) {
+			disableOrcMode(ctx);
+		} else {
+			enableOrcMode(ctx);
+		}
+	}
+
+	function persistState() {
+		pi.appendEntry("orc-mode-state", {
+			enabled: orcModeEnabled,
+			savedTools,
+		});
+	}
+
+	// Register /orc command
+	pi.registerCommand("orc", {
+		description: "Toggle orchestrator mode (only subagent tool available)",
+		handler: async (_args, ctx) => {
+			toggleOrcMode(ctx);
+		},
+	});
+
+	// Modify system prompt when orc mode is active
+	pi.on("before_agent_start", async (event) => {
+		if (!orcModeEnabled) return;
+
+		const orcSystemPrompt = `${event.systemPrompt}
+
+## ORCHESTRATOR MODE
+
+You are operating as an **orchestrator**. Your primary role is to coordinate, plan, and delegate work—not to implement directly.
+
+### Your Tools
+You only have access to the **subagent** tool. Use it to delegate all work:
+- **scout**: Fast codebase recon, find files, return compressed context for handoff
+- **planner**: Create implementation plans from context and requirements
+- **worker**: General-purpose subagent with full capabilities for implementation
+- **reviewer**: Code review specialist for quality and security analysis
+- **oracle**: Deep analysis, debugging, and architecture decisions
+
+### Subagent Modes
+
+**Single agent:**
+\`\`\`typescript
+{ agent: "explorer", task: "Find all auth-related files" }
+\`\`\`
+
+**Parallel tasks** (independent work):
+\`\`\`typescript
+{ tasks: [
+  { agent: "explorer", task: "Find frontend modules" },
+  { agent: "explorer", task: "Find backend modules" }
+]}
+\`\`\`
+
+**Chain** (sequential pipeline with \`{previous}\` carrying output forward):
+\`\`\`typescript
+{ chain: [
+  { agent: "explorer", task: "Gather context for auth refactor" },
+  { agent: "oracle", task: "Analyze and plan based on {previous}" },
+  { agent: "operator" },  // defaults to {previous}
+  { agent: "oracle", task: "Review changes from {previous}" }
+]}
+\`\`\`
+
+**Chain with parallel fan-out/fan-in:**
+\`\`\`typescript
+{ chain: [
+  { agent: "explorer", task: "Find all service modules" },
+  { parallel: [
+    { agent: "operator", task: "Refactor auth service from {previous}" },
+    { agent: "operator", task: "Refactor user service from {previous}" }
+  ]},
+  { agent: "oracle", task: "Review all changes from {previous}" }
+]}
+\`\`\`
+
+### Chain Variables
+- \`{task}\` - Original task from first step
+- \`{previous}\` - Output from prior step (or aggregated parallel outputs)
+- \`{chain_dir}\` - Shared artifacts directory for inter-step files
+
+### Workflow
+1. **Analyze** the user's request
+2. **Plan** the approach (break into steps if complex)
+3. **Delegate** each task to the appropriate subagent
+4. **Synthesize** results and report back
+
+### Guidelines
+- Always use subagent to delegate work
+- For complex multi-step tasks, use chain mode
+- For independent tasks, use parallel mode
+- Use parallel-in-chain for fan-out/fan-in patterns
+- Provide clear, specific task descriptions to subagents
+- After subagents complete, summarize findings or confirm changes`;
+
+		return { systemPrompt: orcSystemPrompt };
+	});
+
+	// Initialize state on session start
+	pi.on("session_start", async (_event, ctx) => {
+		// Check CLI flag
+		if (pi.getFlag("orc") === true) {
+			// Defer enabling to let other extensions initialize first
+			setTimeout(() => {
+				if (!orcModeEnabled) {
+					enableOrcMode(ctx);
+				}
+			}, 0);
+			return;
+		}
+
+		// Restore state from session
+		const entries = ctx.sessionManager.getEntries();
+		const stateEntry = entries
+			.filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "orc-mode-state")
+			.pop() as { data?: { enabled: boolean; savedTools?: string[] | null } } | undefined;
+
+		if (stateEntry?.data) {
+			if (stateEntry.data.enabled) {
+				savedTools = stateEntry.data.savedTools ?? null;
+				orcModeEnabled = true;
+				pi.setActiveTools(ORC_MODE_TOOLS);
+			}
+		}
+
+		updateStatus(ctx);
+	});
+}
dots/pi/agent/extensions/tmux-reference.ts
@@ -0,0 +1,395 @@
+/**
+ * Tmux Reference Extension - Reference tmux pane content in your prompts
+ *
+ * Features:
+ * - /tmux command to open tmux pane picker
+ * - Shows all panes across all sessions
+ * - Captures pane content (scrollback buffer)
+ * - Inserts @tmux:session:window.pane reference at cursor
+ * - Automatically injects pane content on prompt submit
+ *
+ * Usage:
+ * 1. Type /tmux while editing a prompt
+ * 2. Select a tmux pane from the list
+ * 3. Press Enter to insert the reference
+ * 4. Submit your prompt - pane content will be injected automatically
+ */
+
+import { type ExtensionAPI, type ExtensionContext, type Theme } from "@mariozechner/pi-coding-agent";
+import { matchesKey, visibleWidth } from "@mariozechner/pi-tui";
+import { execSync } from "node:child_process";
+
+// Tmux reference pattern: @tmux:session:window.pane
+const TMUX_REF_PATTERN = /@tmux:([^:\s]+):(\d+)\.(\d+)/g;
+
+interface TmuxPane {
+	sessionName: string;
+	windowIndex: number;
+	paneIndex: number;
+	paneTitle: string;
+	currentCommand: string;
+	width: number;
+	height: number;
+}
+
+function isTmuxAvailable(): boolean {
+	try {
+		execSync("tmux list-sessions", { stdio: "pipe" });
+		return true;
+	} catch {
+		return false;
+	}
+}
+
+function listTmuxPanes(): TmuxPane[] {
+	try {
+		const output = execSync(
+			'tmux list-panes -a -F "#{session_name}\t#{window_index}\t#{pane_index}\t#{pane_title}\t#{pane_current_command}\t#{pane_width}\t#{pane_height}"',
+			{ encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] },
+		);
+
+		return output
+			.trim()
+			.split("\n")
+			.filter((line) => line.trim())
+			.map((line) => {
+				const [sessionName, windowIndex, paneIndex, paneTitle, currentCommand, width, height] =
+					line.split("\t");
+				return {
+					sessionName: sessionName!,
+					windowIndex: parseInt(windowIndex!, 10),
+					paneIndex: parseInt(paneIndex!, 10),
+					paneTitle: paneTitle || "",
+					currentCommand: currentCommand || "",
+					width: parseInt(width!, 10),
+					height: parseInt(height!, 10),
+				};
+			});
+	} catch {
+		return [];
+	}
+}
+
+function capturePaneContent(pane: TmuxPane, lines = 500): string {
+	try {
+		const target = `${pane.sessionName}:${pane.windowIndex}.${pane.paneIndex}`;
+		const output = execSync(`tmux capture-pane -t "${target}" -p -S -${lines}`, {
+			encoding: "utf8",
+			stdio: ["pipe", "pipe", "pipe"],
+		});
+		return output.trim();
+	} catch (error) {
+		return `[Error capturing pane: ${error instanceof Error ? error.message : String(error)}]`;
+	}
+}
+
+function formatPaneLabel(pane: TmuxPane): string {
+	const target = `${pane.sessionName}:${pane.windowIndex}.${pane.paneIndex}`;
+	const title = pane.paneTitle || pane.currentCommand || "untitled";
+	return `${target} - ${title}`;
+}
+
+/**
+ * Tmux pane picker overlay component with preview
+ */
+class TmuxPickerOverlay {
+	readonly width = 120;
+	private readonly maxVisible = 8;
+	private readonly previewLines = 12;
+
+	private panes: TmuxPane[] = [];
+	private filteredPanes: TmuxPane[] = [];
+	private query = "";
+	private selectedIndex = 0;
+	private scrollOffset = 0;
+	private previewCache = new Map<string, string[]>();
+
+	constructor(
+		private theme: Theme,
+		private done: (result: TmuxPane | null) => void,
+	) {
+		this.refreshPanes();
+	}
+
+	private refreshPanes(): void {
+		this.panes = listTmuxPanes();
+		this.filterPanes();
+	}
+
+	private filterPanes(): void {
+		if (!this.query) {
+			this.filteredPanes = this.panes;
+		} else {
+			const lowerQuery = this.query.toLowerCase();
+			this.filteredPanes = this.panes.filter(
+				(p) =>
+					p.sessionName.toLowerCase().includes(lowerQuery) ||
+					p.paneTitle.toLowerCase().includes(lowerQuery) ||
+					p.currentCommand.toLowerCase().includes(lowerQuery),
+			);
+		}
+		this.selectedIndex = 0;
+		this.scrollOffset = 0;
+	}
+
+	private getPreview(pane: TmuxPane): string[] {
+		const key = `${pane.sessionName}:${pane.windowIndex}.${pane.paneIndex}`;
+		if (this.previewCache.has(key)) {
+			return this.previewCache.get(key)!;
+		}
+
+		const content = capturePaneContent(pane, 50);
+		const lines = content.split("\n").filter((l) => l.trim());
+		const lastLines = lines.slice(-this.previewLines);
+		this.previewCache.set(key, lastLines);
+		return lastLines;
+	}
+
+	handleInput(data: string): void {
+		if (matchesKey(data, "escape")) {
+			this.done(null);
+			return;
+		}
+
+		if (matchesKey(data, "return")) {
+			this.done(this.filteredPanes[this.selectedIndex] ?? null);
+			return;
+		}
+
+		if (matchesKey(data, "up")) {
+			if (this.selectedIndex > 0) {
+				this.selectedIndex--;
+				if (this.selectedIndex < this.scrollOffset) {
+					this.scrollOffset = this.selectedIndex;
+				}
+			}
+			return;
+		}
+
+		if (matchesKey(data, "down")) {
+			if (this.selectedIndex < this.filteredPanes.length - 1) {
+				this.selectedIndex++;
+				if (this.selectedIndex >= this.scrollOffset + this.maxVisible) {
+					this.scrollOffset = this.selectedIndex - this.maxVisible + 1;
+				}
+			}
+			return;
+		}
+
+		if (matchesKey(data, "backspace")) {
+			if (this.query.length > 0) {
+				this.query = this.query.slice(0, -1);
+				this.filterPanes();
+			}
+			return;
+		}
+
+		// Regular character input
+		if (data.length === 1 && data.charCodeAt(0) >= 32) {
+			this.query += data;
+			this.filterPanes();
+		}
+	}
+
+	render(_width: number): string[] {
+		const w = this.width;
+		const th = this.theme;
+		const innerW = w - 2;
+		const lines: string[] = [];
+
+		const pad = (s: string, len: number) => {
+			const vis = visibleWidth(s);
+			return s + " ".repeat(Math.max(0, len - vis));
+		};
+
+		const truncate = (s: string, maxW: number) => {
+			if (visibleWidth(s) <= maxW) return s;
+			let result = "";
+			let width = 0;
+			for (const char of s) {
+				const charWidth = visibleWidth(char);
+				if (width + charWidth > maxW - 1) break;
+				result += char;
+				width += charWidth;
+			}
+			return result + "…";
+		};
+
+		const row = (content: string) => th.fg("border", "│") + pad(content, innerW) + th.fg("border", "│");
+
+		// Top border
+		lines.push(th.fg("border", `╭${"─".repeat(innerW)}╮`));
+
+		// Title
+		lines.push(row(` ${th.fg("accent", th.bold("Tmux Panes"))}`));
+
+		// Search input
+		const searchPrompt = th.fg("accent", "❯ ");
+		const searchText = this.query || th.fg("dim", "Search panes...");
+		lines.push(row(` ${searchPrompt}${searchText}`));
+
+		// Divider
+		lines.push(th.fg("border", `├${"─".repeat(innerW)}┤`));
+
+		// Pane list
+		const visiblePanes = this.filteredPanes.slice(
+			this.scrollOffset,
+			this.scrollOffset + this.maxVisible,
+		);
+
+		// Calculate max target width for alignment
+		const targetWidth = 28;
+
+		for (let i = 0; i < this.maxVisible; i++) {
+			if (i < visiblePanes.length) {
+				const pane = visiblePanes[i]!;
+				const actualIndex = this.scrollOffset + i;
+				const isSelected = actualIndex === this.selectedIndex;
+
+				const prefix = isSelected ? th.fg("accent", " ▶ ") : "   ";
+				const targetStr = `${pane.sessionName}:${pane.windowIndex}.${pane.paneIndex}`;
+				const fittedTarget = visibleWidth(targetStr) > targetWidth
+					? truncate(targetStr, targetWidth)
+					: pad(targetStr, targetWidth);
+				const target = th.fg("muted", fittedTarget);
+				const separator = th.fg("dim", "│ ");
+
+				const title = pane.paneTitle || pane.currentCommand || "untitled";
+				const fixedWidth = 3 + targetWidth + 2; // prefix + target + separator
+				const maxTitleWidth = Math.max(10, innerW - fixedWidth - 1);
+				const truncatedTitle = truncate(title, maxTitleWidth);
+				const titleStyled = isSelected ? th.fg("text", truncatedTitle) : th.fg("muted", truncatedTitle);
+
+				lines.push(row(`${prefix}${target}${separator}${titleStyled}`));
+			} else if (i === 0 && this.filteredPanes.length === 0) {
+				lines.push(row(th.fg("dim", "   No panes found")));
+			} else {
+				lines.push(row(""));
+			}
+		}
+
+		// Scroll indicator
+		if (this.filteredPanes.length > this.maxVisible) {
+			const shown = `${this.scrollOffset + 1}-${Math.min(
+				this.scrollOffset + this.maxVisible,
+				this.filteredPanes.length,
+			)}`;
+			const total = this.filteredPanes.length;
+			lines.push(row(th.fg("dim", ` (${shown} of ${total})`)));
+		} else {
+			lines.push(row(""));
+		}
+
+		// Preview section
+		lines.push(th.fg("border", `├${"─".repeat(innerW)}┤`));
+
+		const selectedPane = this.filteredPanes[this.selectedIndex];
+		if (selectedPane) {
+			const target = `${selectedPane.sessionName}:${selectedPane.windowIndex}.${selectedPane.paneIndex}`;
+			lines.push(row(` ${th.fg("accent", "Preview:")} ${th.fg("dim", target)}`));
+			lines.push(row(""));
+
+			const preview = this.getPreview(selectedPane);
+			for (let i = 0; i < this.previewLines; i++) {
+				const previewLine = preview[i] ?? "";
+				const truncatedPreview = truncate(previewLine, innerW - 2);
+				lines.push(row(` ${th.fg("dim", truncatedPreview)}`));
+			}
+		} else {
+			lines.push(row(th.fg("dim", " No pane selected")));
+			for (let i = 0; i < this.previewLines + 1; i++) {
+				lines.push(row(""));
+			}
+		}
+
+		// Footer
+		lines.push(th.fg("border", `├${"─".repeat(innerW)}┤`));
+		lines.push(row(th.fg("dim", " ↑↓ navigate  [Enter] select  [Esc] cancel")));
+
+		// Bottom border
+		lines.push(th.fg("border", `╰${"─".repeat(innerW)}╯`));
+
+		return lines;
+	}
+
+	invalidate(): void {}
+	dispose(): void {}
+}
+
+/**
+ * Resolve tmux references in a prompt and capture content
+ */
+function resolveTmuxReferences(prompt: string): { resolvedPrompt: string; contexts: string[] } {
+	const contexts: string[] = [];
+	const panes = listTmuxPanes();
+
+	const resolvedPrompt = prompt.replace(TMUX_REF_PATTERN, (match, sessionName, windowIndex, paneIndex) => {
+		const pane = panes.find(
+			(p) =>
+				p.sessionName === sessionName &&
+				p.windowIndex === parseInt(windowIndex, 10) &&
+				p.paneIndex === parseInt(paneIndex, 10),
+		);
+
+		if (pane) {
+			const content = capturePaneContent(pane);
+			const target = `${pane.sessionName}:${pane.windowIndex}.${pane.paneIndex}`;
+			const title = pane.paneTitle || pane.currentCommand || "untitled";
+			contexts.push(
+				`## Tmux Pane: ${target}\n**Title:** ${title}\n**Command:** ${pane.currentCommand}\n\n\`\`\`\n${content}\n\`\`\``,
+			);
+			return match;
+		}
+		return match;
+	});
+
+	return { resolvedPrompt, contexts };
+}
+
+export default function (pi: ExtensionAPI) {
+	pi.registerCommand("tmux", {
+		description: "Insert tmux pane reference",
+		handler: async (_args, ctx) => {
+			if (!ctx.hasUI) {
+				ctx.ui.notify("Tmux picker requires interactive mode", "error");
+				return;
+			}
+
+			if (!isTmuxAvailable()) {
+				ctx.ui.notify("Tmux is not running", "error");
+				return;
+			}
+
+			const result = await ctx.ui.custom<TmuxPane | null>(
+				(_tui, theme, _kb, done) => new TmuxPickerOverlay(theme, done),
+				{ overlay: true },
+			);
+
+			if (result) {
+				const target = `${result.sessionName}:${result.windowIndex}.${result.paneIndex}`;
+				const currentText = ctx.ui.getEditorText();
+				ctx.ui.setEditorText(currentText + `@tmux:${target} `);
+				ctx.ui.notify(`Inserted reference to: ${formatPaneLabel(result).slice(0, 40)}...`, "info");
+			}
+		},
+	});
+
+	// Inject context when prompt contains tmux references
+	pi.on("before_agent_start", async (event, _ctx) => {
+		const { contexts } = resolveTmuxReferences(event.prompt);
+
+		if (contexts.length === 0) {
+			return;
+		}
+
+		const contextMessage = contexts.join("\n\n---\n\n");
+
+		return {
+			message: {
+				customType: "tmux-reference",
+				content: contextMessage,
+				display: true,
+			},
+		};
+	});
+}