@oguzhaneren/pi-shunt
v0.2.3
Published
Token optimization extension for pi - routes I/O-heavy operations to cheaper worker models
Maintainers
Readme
Pi-Shunt
Cut your pi token usage by ~90% on large file reads and code generation
Port of Spotify's shunt plugin from Claude Code to pi.
Token optimization extension that routes I/O-heavy operations to cheaper worker models – read delegation saves input tokens, write delegation saves output tokens.
Quick Start
# Install globally
git clone https://github.com/oguzhaneren/pi-shunt.git ~/.pi/agent/extensions/pi-shunt
cd ~/.pi/agent/extensions/pi-shunt
npm install
# or
pi install npm:@oguzhaneren/pi-shunt
# Start pi – shunt is active immediately
piThat's it! Shunt automatically intercepts large file reads and routes them to a cheaper worker model.
What It Does
- Intercepts
readtool calls on files >350 lines - Intercepts
bashcat/head/tail on large files - Delegates to Gemini Flash (or your chosen worker model)
- Returns structured summaries instead of full file content
- Saves 82-97% of tokens on large file reads
- Delegates boilerplate code generation (tests, stubs, config, docs) to the worker, writing directly to disk
Before (without shunt)
User: "What does Service.java do?"
Pi reads: 4,014 lines → 33,684 tokensAfter (with shunt)
User: "What does Service.java do?"
Shunt blocks: "File is 4,014 lines, use shunt_read"
Pi calls: shunt_read --question "What does this service do?" --paths Service.java
Worker analyzes: File → Structured summary (5,737 tokens)
Savings: 82%How It Works
Three layers, from hard enforcement to soft guidance:
- Hooks – Intercept Read and Bash tool calls, block large file reads
- Tools –
shunt_readdelegates file analysis,shunt_writedelegates code generation - Worker – Fresh pi subprocess with cheaper model analyzes files and generates code
When pi tries to read a file >350 lines (configurable), shunt blocks it and suggests using shunt_read instead. Pi then calls that tool, which spawns a worker pi process to analyze the files and return a structured summary.
Architecture
┌─────────────────────────────────────────────────────────┐
│ Main Pi Session (Frontier Model) │
│ │
│ User: "What does Service.java do?" │
│ ↓ │
│ Pi attempts: read(Service.java) │
│ ↓ │
│ Shunt intercepts: "File too large, use shunt_read" │
│ ↓ │
│ Pi calls: shunt_read(question, paths) │
│ ↓ │
│ ┌─────────────────────────────────────────┐ │
│ │ Worker Pi Subprocess (Gemini Flash) │ │
│ │ │ │
│ │ Receives: <file>...</file> + question │ │
│ │ Analyzes: Code structure, patterns │ │
│ │ Returns: Structured summary │ │
│ └─────────────────────────────────────────┘ │
│ ↓ │
│ Pi receives summary (not full file) │
│ ↓ │
│ Pi responds: "This service..." │
└─────────────────────────────────────────────────────────┘Code generation follows the same pattern:
Pi calls: shunt_write(spec, reference, target)
↓
Worker Pi Subprocess (write mode)
Receives: spec + reference file patterns
Generates: code matching the reference
Writes: directly to target file on disk
↓
Pi receives: "Wrote 140 lines to UserService.test.ts [shunt: ... tokens]"
(Generated code never enters the main model's context)Installation
Option 1: Global (all projects)
# Clone to pi extensions directory
git clone https://github.com/oguzhaneren/pi-shunt.git ~/.pi/agent/extensions/pi-shunt
# Install dependencies
cd ~/.pi/agent/extensions/pi-shunt
npm installOption 2: Project-local
# In your project
git clone https://github.com/oguzhaneren/pi-shunt.git .pi/extensions/pi-shunt
cd .pi/extensions/pi-shunt
npm installOption 3: Via npm
pi install npm:@oguzhaneren/pi-shuntConfiguration
All settings use SHUNT_* environment variables. Pi-shunt resolves them from four sources, highest priority first:
- Real environment variables (export in your shell profile)
.pi/settings.json(project)envblock~/.pi/agent/settings.json(global)envblock- Built-in defaults
Example ~/.pi/agent/settings.json:
{
"env": {
"SHUNT_ENABLED": "true",
"SHUNT_MIN_LINES": "350",
"SHUNT_WORKER_MODEL": "gemini-2.5-flash",
"SHUNT_WORKER_TEMPERATURE": "0.2"
}
}Note: pi 0.85.x does not apply the settings.json
envblock to its own process, so pi-shunt reads those files directly. Restart pi (or/reload) after editing settings.json. If aSHUNT_*var is exported in your shell, the extension sees it viaprocess.envand it overrides settings.json.
Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| SHUNT_ENABLED | true | Enable/disable extension |
| SHUNT_MIN_LINES | 350 | Line count above which reads are blocked |
| SHUNT_MAX_PAYLOAD_BYTES | 400000 | Maximum request size |
| SHUNT_WORKER_MODEL | gemini-2.5-flash | Model to use for analysis |
| SHUNT_WORKER_TEMPERATURE | 0.2 | Temperature for worker model |
| SHUNT_WORKER_INSTRUCTIONS | (default) | Custom system prompt for worker |
| SHUNT_READ_ENABLED | true | Enable/disable read interception + shunt_read |
| SHUNT_WRITE_ENABLED | true | Enable/disable shunt_write |
| SHUNT_WRITE_INSTRUCTIONS | (default) | Custom system prompt for the write worker |
| SHUNT_CYCLE_KEY | ctrl+alt+s | Keybinding for the cycle shortcut |
Usage
Automatic (recommended)
Just use pi normally. When it tries to read a large file, shunt intercepts:
You: "Explain how this service works"
Pi: I'll read the service file...
[Shunt blocks read]
Pi: I'll use shunt_read instead
[Worker analyzes file]
Pi: Based on the analysis, this service...Manual
Call shunt_read directly:
/shunt_read --question "What does this service do?" --paths src/Service.javaFollow-up questions
Each call is independent. Ask again with the same files:
/shunt_read --question "Which methods call the database?" --paths src/Service.javaRe-sending files is free where it matters – they go to the worker, not the frontier model.
Code generation (shunt_write)
For boilerplate that is predictable from existing patterns, shunt_write generates the code with the worker and writes it directly to a file. The generated code never enters the frontier model's context – only a summary comes back.
Pi: I'll write tests for UserService following the existing patterns
Pi calls: shunt_write spec="Write unit tests for UserService..." reference=tests/OrderTest.ts target=tests/UserService.test.ts
Worker generates: code matching OrderTest.ts patterns
Wrote 140 lines to tests/UserService.test.ts [shunt_write: ... tokens]Best fits: tests, mocks, fixtures, config stubs, type stubs, docstrings.
Requirements:
referenceis required – context-free generation fits nothing in your project- For follow-up calls, pass the file the previous call just generated as the
reference - Review the file after writing and make surgical edits for the ~5-20% that needs judgment
Do not use for: editing existing logic or architecture decisions – those need exact content in the frontier model's context.
Commands
| Command | Effect |
|---------|--------|
| /shunt:config | Show current configuration (toggles, cycle key, env vars) |
| /shunt:toggle | Enable/disable all shunt behavior (hooks + tools) |
| /shunt:toggle:read | Enable/disable read interception + shunt_read only |
| /shunt:toggle:write | Enable/disable shunt_write only |
| /shunt:stats | Show session statistics (reads intercepted, tokens saved, etc.) |
| /shunt:stats:reset | Reset session statistics |
When a tool is disabled, calling it returns a clear "disabled" message instead of delegating to the worker. With the master toggle off, both tools refuse and the hooks let all reads pass through.
All toggles report the resulting state, e.g. /shunt:toggle:read off while write is on reports only write (read off, write on). Possible states: all on, only read, only write, all off.
Cycle shortcut
ctrl+alt+s cycles through all four states: all on → only read → only write → all off. No reload needed – toggles apply to the next call. Rebind via SHUNT_CYCLE_KEY.
What Doesn't Get Delegated
Shunt knows when NOT to delegate:
- Targeted reads –
readwith offset/limit (pi already knows what it needs) - Small files – Under threshold (delegation overhead > savings)
- Nonexistent files – Let
readhandle the error - Piped commands –
cat file | grep(targeted read) - Redirections –
cat file > out(not reading into context)
For editing, pi still needs to read the exact content. Shunt allows targeted reads (with offset/limit) to pass through for this reason.
Examples
Single large file
/shunt_read --question "What does SpotifyUri.java do?" --paths src/main/java/SpotifyUri.java- Without shunt: 33,684 tokens
- With shunt: 5,737 tokens
- 82% savings
Source + test pair
/shunt_read --question "How do these work together?" --paths src/PromotionRuleRepository.java tests/PromotionRuleRepositoryTest.java- Without shunt: 75,990 tokens
- With shunt: 4,148 tokens
- 94% savings
Multi-file analysis
/shunt_read --question "How is permission checking implemented across these handlers?" --paths src/handlers/*.java- Without shunt: 16,221 tokens
- With shunt: 821 tokens
- 94% savings
Benchmarks
This repo's test fixtures (worker: qwen3.8-125b)
| Scenario | Without Shunt | With Shunt | Savings | |----------|---------------|------------|---------| | Single large file (602 lines) | 12,006 tokens | 379 tokens | 96.8% | | Multi-file cross-read (3 files) | 12,687 tokens | 384 tokens | 97.0% | | Source + test pair | 681 tokens | 424 tokens | 38-70%* | | Code generation | 681 tokens | 404 tokens | ~41%* |
Large file reads consistently save 96-97%. The small-pair numbers swing run to run because worker answer verbosity dominates on tiny corpora.
Run your own: npm run test:benchmark
Limitations
Can't delegate editing
Worker summaries don't include reliable line numbers. If pi needs to make edits, it has to read the specific section directly. Shunt allows targeted reads (with offset/limit) for this reason.
Can't delegate reasoning
Worker models find surface patterns but can miss subtle bugs. Frontier models are better at architecture decisions, debugging, and safety-critical analysis.
Latency
Each delegation spawns a pi subprocess: 10-30 seconds typical. Acceptable for large reads, counterproductive for small ones (hence the threshold).
Payload size limits
Request must fit in memory. Default max: 400KB. Split large batches if needed.
Development
Project Structure
pi-shunt/
├── extensions/ # Pi extension source code
│ ├── index.ts # Main entry point (hooks, toggles, cycle)
│ ├── lib/ # Core logic (config, worker, stats, utils)
│ ├── tools/ # shunt_read + shunt_write tools
│ └── README.md # Extension documentation
├── tests/ # Unit tests, integration tests, benchmarks
│ ├── unit.test.ts # Pure logic tests (no pi needed)
│ ├── integration.test.ts # Worker delegation tests (needs pi)
│ ├── benchmark.ts # Token savings measurements
│ ├── fixtures/ # Test files (602-line websocket-handler, etc.)
│ └── README.md # Test suite documentation
├── skills/ # Pi skills (shunt-read, shunt-write)
├── shunt/ # Original Claude Code plugin (reference)
└── README.md # This fileLocal Development
# Install dependencies
npm install
# Typecheck + tests
npm run typecheck
npm test
# Test locally
pi -e ./extensions/index.ts
# Or symlink
ln -s $(pwd)/extensions ~/.pi/agent/extensions/pi-shuntRunning Tests
# All tests (unit + integration, needs pi in PATH)
npm test
# Unit tests only (no pi needed)
npm run test:unit
# Integration tests (needs pi)
npm run test:integration
# Token savings benchmarks
npm run test:benchmarkSee tests/README.md for details.
Troubleshooting
"Failed to spawn pi"
Ensure pi is in your PATH:
which piIf not found, install it:
npm install -g @earendil-works/pi-coding-agentWorker returns empty/wrong answers
Check worker model availability:
pi --model gemini-2.5-flash --message "test"Try a different worker model:
{
"env": {
"SHUNT_WORKER_MODEL": "gemini-2.0-flash"
}
}Thinking-only responses: reasoning models sometimes answer with a thinking block and no text (the model has "decided to inspect the repo" but shunt workers run with --no-tools). The error now reports this explicitly, e.g. Worker returned no text. Worker only reasoned: "...". Write-mode instructions include "you have no tools, always output code" to prevent it; a custom worker model may need a similar nudge – set SHUNT_WRITE_INSTRUCTIONS / SHUNT_WORKER_INSTRUCTIONS.
File not found errors
Paths are resolved from pi's working directory. Use absolute paths or relative to cwd:
/shunt_read --question "..." --paths ./src/Service.javaContributing
Contributions welcome! Please:
- Fork the repository
- Create a feature branch
- Add tests for new functionality
- Submit a pull request
See CONTRIBUTING.md for guidelines.
License
MIT
Credits
- Original shunt concept: Spotify
- Blog post: Portal by Spotify: Cut My Claude Code Token Usage by 90%
