A detailed comparison of LLM tool calls with mish vs without mish (bare shell), using real commands run against the mish codebase (43,294 lines of Rust across 70 source files, 1,311 unit tests).
All output in this document was captured from a single live mish serve session on 2026-03-04. "Without mish" baselines were captured from the same machine in the same build state.
- Test Setup
- What mish Exports
- Challenge 1: Build a Rust Project
- Challenge 2: Run 1,311 Unit Tests
- Challenge 3: Error Diagnosis
- Challenge 4: Watch-Pattern Filtering
- Challenge 5: Background Process Lifecycle
- Challenge 6: Structured Output Passthrough
- Metrics Summary
- Ambient State: The Process Table Digest
Environment:
- macOS Darwin 24.6.0, Apple Silicon
- Rust 1.85, cargo, zsh
- mish v0.1.0 running as MCP server via
mish serve - Claude Code connected over stdio MCP transport
The challenge: An LLM agent needs to build, test, and debug a 43K-line Rust project. Every tool call consumes tokens. How much context does mish save, and what intelligence does it add?
Methodology: Each challenge runs the same command through both:
- Without mish: Claude Code's built-in
Bashtool (raw shell execution) - With mish: The
sh_run/sh_spawn/sh_interactMCP tools
We measure: lines returned, structured metadata, error diagnostics, and token efficiency.
Every mish response is compact, symbol-prefixed text — not JSON. The format is designed for LLM consumption: unambiguous, ~5x fewer tokens than JSON, zero parsing overhead.
| Symbol | Meaning |
|---|---|
+ |
Success |
! |
Error |
→ |
Narration / recommendation |
⚠ |
Dangerous |
~ |
Diagnostic / suggestion |
- |
Removed / killed |
+ exit:0 1.2s condense (29→16) ← header: symbol exit elapsed category (total→shown)
Compiling mish v0.1.0 ← body: post-processed output (VTE-stripped, deduped, truncated)
warning: field `config` is never read
Finished `dev` profile target(s)
~ file_exists src/main.rs (modified 2m) ← enrichment: diagnostics on failure (~ kind message)
→ consider --message-format=json (quieter output) ← recommendation: suggested flag for next run
[procs] server:running:30s watcher:running:15s ← digest: ambient process state (MCP only)
The header packs exit code, wall-clock timing, command category, and compression ratio into one line. Body follows without indentation. Enrichment, recommendations, and process digest are appended when present.
sh_spawn:
+ spawned server pid:12345 session:main matched:1.7s
Server listening on port 3000
[procs] server:running:1.7s
sh_interact (status / read_tail / kill):
+ server status running pid:12345 30s session:main
+ server read_tail 5 lines running
- server killed
sh_session (list / create / close):
+ session list
main /bin/zsh /Users/scott/projects/mish (2 procs)
+ session create test-session ready
+ session close test-session
sh_help: Text reference card (see end of document).
Command: cargo build
Tool call: Bash({ command: "cargo build 2>&1" })
Response: Plain text, 28 lines
Compiling mish v0.1.0 (/Users/scottmeyer/projects/mish)
warning: field `config` is never read
--> src/mcp/server.rs:71:5
|
67 | pub struct McpServer {
| --------- field in this struct
...
71 | config: Arc<MishConfig>,
| ^^^^^^
|
= note: `#[warn(dead_code)]` on by default
warning: field `shell_path` is never read
--> src/session/shell.rs:89:5
|
87 | pub struct ShellProcess {
| ------------ field in this struct
88 | pty: PtyCapture,
89 | shell_path: String,
| ^^^^^^^^^^
warning: function `get_output_tail` is never used
--> src/tools/sh_spawn.rs:371:4
|
371 | fn get_output_tail(table: &ProcessTable, alias: &str, max_lines: usize) -> String {
| ^^^^^^^^^^^^^^^
warning: `mish` (lib) generated 3 warnings
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.98s
What the LLM gets: Raw compiler output. No exit code (must parse text). No timing. No category. No suggestion for next time.
Tool call: sh_run({ cmd: "cargo build 2>&1", timeout: 120 })
Response: Compact text, 16 lines shown from 29 total
+ exit:0 1.2s condense (29→16)
Compiling mish v0.1.0 (/Users/scottmeyer/projects/mish)
Building [=======================> ] .../...: mish
warning: field `config` is never read (x3)
--> src...:...:5 (x2)
| (x3)
67 | pub struct McpServer {
| --------- field in this struct (x2)
...
71 | config: Arc<MishConfig>,
| ^^^^^^
= note: `#[warn(dead_code)]` on by default
(x3)
87 | pub struct ShellProcess {
88 | pty: PtyCapture,
89 | shell_path: String,
| ^^^^^^^^^^
warning: function `get_output_tail` is never used → src...:... (x2)
→ consider --message-format=json (Consider adding --message-format=json for quieter output)
| Metric | Without mish | With mish |
|---|---|---|
| Lines returned | 28 | 16 |
| Exit code | (must parse text) | exit:0 in header |
| Timing | (unknown) | 1.2s in header |
| Category | (unknown) | condense in header |
| Compression ratio | (unknown) | (29→16) in header |
| Deduplication | None | Repeated warning structure collapsed with (x3) markers |
| Next-run suggestion | None | → consider --message-format=json |
| Process awareness | None | Empty (no [procs] line = nothing running) |
Token savings: ~43% fewer output lines. But the real value is structured metadata in the header — the LLM knows exit code, timing, category, and compression ratio at a glance.
Command: cargo test --lib
Tool call: Bash({ command: "cargo test --lib 2>&1" })
Response: Plain text, 1,342 lines
(last 5 lines shown — full output is 1,342 lines of individual test names)
test tools::sh_spawn::tests::spawn_alias_conflict_error ... ok
test tools::sh_spawn::tests::spawn_invalid_wait_for_regex ... ok
test result: ok. 1311 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 21.21s
What the LLM gets: 1,342 lines of test foo ... ok repeated 1,311 times. All consumed as context tokens. The only useful line is the last one.
Tool call: sh_run({ cmd: "cargo test --lib 2>&1", timeout: 120 })
Response: Compact text, 201 lines shown from 1,341 total
+ exit:0 21.9s condense (1341→201)
warning: field `config` is never read → src...:... (x675)
(x4)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.06s
Running unittests src/lib.rs (target/debug/deps/mish-9ff88bb7b4389a71)
test audit::logger::tests::command_record_log_level_is_info ... ok (x2)
test audit::logger::tests::command_none_omitted_from_json ... ok (x2)
test audit::logger::tests::disabled_logger_no_crash ... ok
...
... [262 lines truncated] ...
...
test tools::sh_spawn::tests::spawn_alias_conflict_error ... ok
test tools::sh_spawn::tests::spawn_wait_for_timeout ... ok
→ consider --message-format=json (Consider adding --message-format=json for quieter output)
| Metric | Without mish | With mish |
|---|---|---|
| Lines returned | 1,342 | 201 |
| Compression | 1:1 | 6.7:1 |
| Exit code | (parse last line) | exit:0 in header |
| Duration | (parse last line) | 21.9s in header |
| Deduplication | None | Repeated ... ok patterns collapsed with (xN) |
| Oreo truncation | None | [262 lines truncated] — head + tail preserved |
| Warnings | Buried in 1,342 lines | Promoted to top, deduplicated |
Token savings: ~85% reduction (1,342 → 201 lines). The Oreo truncation keeps the first tests (to see the run start) and last tests (to see the final result), hiding the repetitive middle. Dedup collapses repeated ... ok patterns.
Command: cp /tmp/nonexistent_source.txt /var/log/readonly_dest/
Tool call: Bash({ command: "cp /tmp/nonexistent_source.txt /var/log/readonly_dest/ 2>&1" })
Response: 1 line
cp: directory /var/log/readonly_dest does not exist
What the LLM gets: A single error message. To diagnose, it must now make follow-up tool calls:
ls /tmp/nonexistent_source.txt— does source exist?ls -la /var/log/— does dest parent exist?ls /var/log/readonly_dest/— does dest exist?stat /var/log/— what are the permissions?
That's 4 additional round trips before the LLM can reason about the failure.
Tool call: sh_run({ cmd: "cp /tmp/nonexistent_source.txt /var/log/readonly_dest/" })
Response: Compact text with enrichment
! exit:1 153ms narrate (1→1)
cp: directory /var/log/readonly_dest does not exist
~ source nonexistent_source.txt (not found) ✗
~ path /var/log ✓ /var/log/readonly_dest ✗
~ nearest /var/log contains: DiagnosticMessages/, apache2/, asl/, ...
~ nearest /tmp contains: (19 entries)
~ source /tmp/nonexistent_source.txt ✗
~ dest /var/log/readonly_dest/ ✗
~ dest_parent log (1.3 KB, rwxr-xr-x) ✓
| Metric | Without mish | With mish |
|---|---|---|
| Error message | 1 line | 1 line (same) |
| Source file status | (unknown — needs follow-up) | ~ source ... ✗ not found |
| Dest path walk | (unknown — needs follow-up) | ~ path /var/log ✓ /var/log/readonly_dest ✗ |
| Dest parent permissions | (unknown — needs follow-up) | ~ dest_parent rwxr-xr-x |
| Nearby files | (unknown — needs follow-up) | ~ nearest lines (for typo detection) |
| Follow-up calls needed | 4 | 0 |
| Total round trips | 5 | 1 |
The enrichment budget is <100ms (153ms total including command execution), read-only, non-speculative. mish pre-fetches exactly the diagnostics the LLM would ask for next — path walks, source/dest existence, permissions, nearest directory contents — and delivers them as ~ lines in the same response.
Command: cargo test --lib with watch="warning" and unmatched="drop"
This is the same 1,311-test run from Challenge 2, but now we only want compiler warnings.
The LLM would need to:
- Run
cargo test --lib 2>&1 - Receive all 1,342 lines
- Parse them in context to find warnings
- Or run
cargo test --lib 2>&1 | grep warning— but pipes in Claude Code can cause zombie processes
Tool call: sh_run({
cmd: "cargo test --lib 2>&1",
timeout: 120,
watch: "warning",
unmatched: "drop"
})
Response: 2 lines from 1,341
+ exit:0 21.5s condense (1341→2)
warning: field `config` is never read → src...:... (x581)
→ consider --message-format=json (Consider adding --message-format=json for quieter output)
| Metric | Without mish | With mish + watch |
|---|---|---|
| Lines consumed | 1,342 | 2 |
| Compression | 1:1 | 670:1 |
| Precision | Must scan all output | Exact regex matches only |
| Round trips | 1 (but wastes tokens) | 1 (surgical) |
Token savings: 99.9% reduction. The full test suite ran, but only the warning-matched lines were returned. Dedup collapsed 3 structurally similar warnings into one line with an (x581) marker.
An LLM needs to start a long-running process, confirm it's ready, check on it later, and clean it up. Without mish, this requires polling loops and PID tracking.
# Step 1: Start background process
Bash({ command: "python3 -c \"...\" &; echo $!" })
# Response: "12345" (must parse PID)
# Step 2: Check if ready (poll loop)
Bash({ command: "..." }) # ← no good way to wait for output pattern
# Step 3: Check status
Bash({ command: "kill -0 12345 && echo running || echo stopped" })
# Step 4: Read output
Bash({ command: "..." }) # ← output was lost, not captured
# Step 5: Kill
Bash({ command: "kill 12345" })Problems: Output is lost after the process backgrounds. No way to wait for a regex match. Must manually track PIDs. No structured state.
Step 1: Spawn with wait-for pattern
Tool call: sh_spawn({
alias: "watcher",
cmd: "python3 -c \"import time; [print(f'tick {i}', flush=True) or time.sleep(0.5) for i in range(20)]\"",
wait_for: "tick 3",
timeout: 10
})
+ spawned watcher pid:61235 session:main matched:1.7s
tick 3
[procs] watcher:running:1.7s
The process started, and mish polled output until tick 3 appeared (1.7s), then returned immediately. The LLM knows the process is ready without polling.
Step 2: Check status
Tool call: sh_interact({ alias: "watcher", action: "status" })
+ watcher status running pid:61235 1.7s session:main
[procs] watcher:running:1.7s
Step 3: Read recent output
Tool call: sh_interact({ alias: "watcher", action: "read_tail", lines: 10 })
+ watcher read_tail 5 lines running
tick 0
tick 1
tick 2
tick 3
[procs] watcher:running:3.2s
Output is captured in a spool — it's never lost, even for background processes.
Step 4: Kill
Tool call: sh_interact({ alias: "watcher", action: "kill" })
- watcher killed
[procs] watcher:killed:3.2s
| Capability | Without mish | With mish |
|---|---|---|
| Start + wait for ready | Manual poll loop | wait_for: "tick 3" — blocks until match |
| Ready confirmation | Parse stdout (if captured) | matched:1.7s in header |
| Time to ready | Unknown | matched:1.7s |
| Read output later | Lost (backgrounded) | Spool captures everything |
| Process state | kill -0 hack |
running / killed in response |
| Named references | Raw PIDs | alias: "watcher" |
| Cleanup | kill <pid> (must track) |
sh_interact({ alias, action: "kill" }) |
| Ambient awareness | None | [procs] digest on every response |
Command: git log --oneline -20
Tool call: Bash({ command: "git log --oneline -20" })
Response: 20 lines, plain text
a65f671 docs: rewrite README to match landing page install-first flow
d831c27 fix: use macos-14 runner, add x86_64 musl target for Alpine/Docker
5d6c5be feat: squasher block refactor, grammar dialects, fixture tests, and repo cleanup
fbd81aa feat: OG social card, install script, release workflow, and landing page restructure
f0af768 feat: add ohitsmish.com landing page with GitHub Pages deployment
c29ab39 docs: add SHOWCASE.md with side-by-side mish vs bare shell comparison
fe9728e feat: add ansible/apt/brew/curl/gcc/go/rsync/rustc/ssh/systemctl grammars
4213ba9 feat: raw mode interactivity detection for condense handler (mish-p6.d6)
0af3fb9 feat: add kubectl + terraform TOML grammars with fixture tests
cbf4e7f feat: add jest + webpack TOML grammars with fixture tests
310186b test: verify grammar inheritance flows through classifier end-to-end
b615119 feat: stack trace compression via deferred-line mechanism
bbdabe5 feat: enrich truncation markers with error/warning counts from hidden region
bbbd391 feat: add binary output detection to squasher pipeline
ac3b9c7 feat: add sh_session audit action for MCP session log access
570d851 feat: add signal handling to condense event loop
af92231 feat: wire unified Pipeline into CLI condense handler
78a0ff5 feat: wire unified Pipeline into sh_run, close b1/b2/b3/c3/g4
bac2c8d feat: wire ImplicitDedup into classifier Tier 3 path
101ffe3 feat: extract narrate_output() and parse_structured() pipeline-callable functions
Tool call: sh_run({ cmd: "git log --oneline -20" })
+ exit:0 243ms condense (21→20)
a65f671 (HEAD -> main, origin/main) docs: rewrite README to match landing page install-first flow
d831c27 (tag: v0.1.0) fix: use macos-14 runner, add x86_64 musl target for Alpine/Docker
5d6c5be feat: squasher block refactor, grammar dialects, fixture tests, and repo cleanup
fbd81aa feat: OG social card, install script, release workflow, and landing page restructure
f0af768 feat: add ohitsmish.com landing page with GitHub Pages deployment
c29ab39 docs: add SHOWCASE.md with side-by-side mish vs bare shell comparison
fe9728e feat: add ansible/apt/brew/curl/gcc/go/rsync/rustc/ssh/systemctl grammars
4213ba9 feat: raw mode interactivity detection for condense handler (mish-p6.d6)
0af3fb9 feat: add kubectl + terraform TOML grammars with fixture tests
cbf4e7f feat: add jest + webpack TOML grammars with fixture tests (x2)
310186b test: verify grammar inheritance flows through classifier end-to-end
b615119 feat: stack trace compression via deferred-line mechanism
bbdabe5 feat: enrich truncation markers with error/warning counts from hidden region
bbbd391 feat: add binary output detection to squasher pipeline
ac3b9c7 feat: add sh_session audit action for MCP session log access
570d851 feat: add signal handling to condense event loop
af92231 feat: wire unified Pipeline into CLI condense handler
78a0ff5 feat: wire unified Pipeline into sh_run, close b1/b2/b3/c3/g4
bac2c8d feat: wire ImplicitDedup into classifier Tier 3 path
Key difference: mish detected similar commit messages and applied dedup ((x2) marker on the jest + webpack commits). For structured/passthrough commands, mish preserves all content — it doesn't aggressively compress data that's already information-dense. But it still adds the compact header with exit code, timing, category, and line counts.
| Challenge | Raw Lines | mish Lines | Compression | Extra Intelligence |
|---|---|---|---|---|
| cargo build | 28 | 16 | 1.8:1 | exit, timing, category, recommendation |
| cargo test (1,311 tests) | 1,342 | 201 | 6.7:1 | exit, timing, Oreo truncation |
| cargo test + watch | 1,342 | 2 | 670:1 | surgical regex filtering |
| cp failure | 1 | 1 | 1:1 | 7 enrichment diagnostics (saves 4 follow-ups) |
| git log | 20 | 20 | 1:1 | exit, timing, dedup |
| Feature | Value |
|---|---|
| Compact header | + exit:0 1.2s condense (29→16) — everything at a glance |
| Wall-clock timing | Elapsed time in every header |
| Command classification | Category in header (condense/narrate/passthrough/structured/interactive/dangerous) |
| Error enrichment | ~ lines with pre-fetched diagnostics eliminate 3-5 follow-up calls per failure |
| Watch patterns | Regex filtering at the source, not in the LLM's context |
| Deduplication | Template-based grouping: Downloading pkg (x100) |
| Oreo truncation | Head + tail preserved, hidden middle scanned for hazards |
| Hazard markers | [262 lines truncated — N errors in hidden region] |
| Background process management | Named aliases, output spools, wait-for-ready |
| Process table digest | [procs] line on every response — ambient awareness |
| Recommendations | → consider flag (reason) — suggested flags for quieter output |
Every mish MCP response appends a [procs] line when background processes exist. This gives the LLM ambient awareness of all active processes without polling.
When no processes are running: No [procs] line appears. Zero overhead.
When a background process is active:
[procs] watcher:running:1.7s
Multiple processes:
[procs] server:running:30s watcher:running:15s
After killing a process:
[procs] watcher:killed:3.2s
The digest is always present when non-empty, always free. The LLM never needs to ask "what's running?" — it already knows. This eliminates an entire class of status-polling tool calls.
Digest behavior:
- Default mode:
Changed— only processes modified since last response - Caps at 20 entries (keeps all non-terminal + 10 most recent terminal)
- Terminal entries stubified after client sees them once
- Sorted by priority:
awaiting_input→running→ terminal
Available at any time for context recovery (e.g., after LLM context compaction):
Tool call: sh_help({})
# mish reference card
## tools
sh_run
cmd*: string — Command to execute
timeout: integer =300 — Seconds before kill
watch: string — Regex or @preset to filter output
unmatched: string =keep — Handle non-matching lines when watch is set (keep|drop)
sh_spawn
alias*: string — Unique name for this process
cmd*: string — Command to execute
wait_for: string — Regex to match before returning success
timeout: integer =300 — Seconds to wait
sh_interact
alias*: string — Target process alias
action*: string — Action: send | read_tail | signal | kill | status
input: string — For send: string to write (include \n for enter)
lines: integer =50 — For read_tail: number of lines
sh_session
action*: string — Action: list
sh_help
tool: string — Filter to a single tool (sh_run, sh_spawn, sh_interact, sh_session, sh_help)
## squasher max_lines:200 oreo:50/150 max_bytes:65536
## resources sessions:1/5 processes:0/20
Compact text reference card with live resource usage so the LLM knows capacity.
mish is not a shell replacement — it's a context-efficiency layer between the LLM and the shell. The LLM still runs the same commands. But instead of raw text that must be parsed, it gets:
- Compact headers on every response (exit code, timing, category, compression ratio)
- Compressed output for noisy commands (6.7x–670x reduction)
- Pre-fetched diagnostics on errors (
~lines eliminating 3-5 follow-up calls) - Surgical filtering via watch patterns (see only what matters)
- Process lifecycle management with named handles and output spools
- Ambient state via the
[procs]digest (zero-cost awareness)
The result: fewer tokens consumed, fewer round trips, and richer context for decision-making — all in a format that's ~5x more token-efficient than JSON.