Octofs 0.14: Waiting Is Not a Tool Call
The agent ran the test suite. The test suite takes four minutes. The MCP client's idle timeout is sixty seconds.
You can see where this is going. At second sixty the client cancelled the call. The process kept running — nobody told it to stop — while the model, holding a cancellation where its test results should be, did the reasonable thing and ran the suite again. Two test suites, same directory, racing each other over the same build artifacts. The second one failed with a locking error, the model reported the tests as broken, and the tests were fine.
In another session the same model, burned before, developed a workaround: run the build, then call sleep 240, then look. A tool call that does nothing, held open for four minutes, so that a different tool call might have something to show. The model had reinvented polling, badly, because we hadn't given it anything better.
Last time we wrote about octofs, the subject was edits that land on the wrong line. That post ended with a principle: an MCP server's real interface is every string it hands back to the model. The eleven releases since — 0.10.1 through 0.14.1, two weeks — apply the same principle to the slowest string of all: the one the model waits for. The shell is now event-driven. Commands start in the foreground, move to the background on their own if they outlast ten seconds, and the client gets a notification when they finish. Nothing blocks, nothing gets killed, nothing runs twice.
Here's how we got there, wrong turn included.
First fix: prove the call is alive
The sixty-second cancellation had a shallow cause and a deep one. The shallow one: a shell call is silent by nature. A build that's compiling says nothing on the wire for minutes, and to an MCP client silence is indistinguishable from a hung server. So 0.10.2 added liveness heartbeats — while a command runs in the foreground, octofs emits a progress notification every ten seconds, well below any sane idle timeout, so a single missed beat can't cancel the call.
That stopped the killings. It did not touch the deep problem: the call still blocked. A four-minute test suite still cost four minutes of session time in which the model could do nothing — not read the failing file, not prepare the next edit, not think. Heartbeats make waiting survivable. They don't make it useful.
Second fix: background jobs — and the flag we had to delete
0.11.0 introduced background execution: run a command as a job, get a handle back immediately, collect the output later. Each job is an MCP resource with a URI like octofs://jobs/17342-1, readable at any time for its status and output.
It shipped with a background flag on the shell tool, and that flag was a mistake we recognize in hindsight as a familiar one. In 0.9.0 we deleted a --line-mode switch because safety that ships behind a flag is safety most people never turn on. The background flag was the same bug in a different costume: it asked the model to predict the duration of a command before running it. Models are bad at this in exactly the way you'd expect — cargo build is instant on a warm cache and takes six minutes cold, and the flag turned that unknowable fact into a required decision. Guess background for a fast command and you've added a pointless round trip. Guess foreground for a slow one and you're back to the blocked call we started with.
So 0.13.0 deleted the flag and replaced the prediction with a measurement. Every command starts in the foreground. If it's still running at ten seconds, it is automatically promoted to a background job — the same process, not killed, not restarted. Output capture is durable from the first byte, so crossing the deadline loses nothing: whatever the command printed in its foreground life is sitting in the job's log when you read it later.
The tool call returns immediately at promotion, with a resource link carrying the command as its name — so a client can render "make test … still running" without re-deriving what the job was, even after a context compaction. When the process exits, octofs emits notifications/resources/updated for the job's URI. The client reads the resource once and gets the exit code and the output tail. No polling, no held-open call, no orphaned process.
Two details in that flow earned their place the hard way:
- The tail, not the head. A resource read returns at most the last 30 KB of output. Build logs run long, and the verdict — the error, the final test summary — lives at the end. Feeding a model the first 30 KB of a log whose last line says
FAILEDis how you get a confident report that everything passed. - Two delivery paths. Clients on the 2026-07-28 MCP revision that opened a subscription stream get the completion on it; older clients get the unsolicited push the earlier spec allowed. And since 0.13.0, a client that subscribes late — after the job already exited — gets the completion replayed instead of waiting forever on a notification that fired before anyone was listening.
The foreground window also shrank from thirty seconds to ten in 0.13.0, and that's the auto-promotion paying for itself: when crossing the boundary costs nothing — same process, durable output, a notification at the end — there's no reason to hold the session hostage for half a minute just in case the command finishes at second twenty-five.
Third fix: let jobs run next to each other
0.11.0 was conservative: one job per directory, full stop. Safe, and too blunt — it serialized a build and a log tail that had no business waiting on each other.
0.14.0 narrowed the guard to the one case that's actually a bug: the identical command already running in the same directory. That's not concurrency, that's the double-fired test suite from the opening story, and instead of racing it, octofs rejects it and tells the model precisely what to do:
The same shell command is already running as background job
octofs://jobs/17342-1 (`cargo test`). Wait for its completion — you will
get a resources/updated notification with its output — instead of
starting a duplicate. Independent commands may run concurrently in this
directory.
Distinct commands run side by side. The duplicate gets an error that is, once again, the recovery instruction.
And a hard line under all of it
With the server doing the waiting, a model burning a tool call on sleep 240 stopped being a clever workaround and became pure waste. So 0.10.4 added it to the shell misuse list, next to watch and top:
Waiting with a bare `sleep` is forbidden — it burns the whole tool call
doing nothing.
To wait for a condition, poll it in a loop (sleep inside a loop body
is allowed):
until <check>; do sleep 2; done
To wait for a command you started, run it normally; long-running
commands automatically move to the background and notify you when
they finish.
Same policy as 0.9.0's grep rejection: don't hint, fail — and put the correct move in the error. Octofs rejects a bare sleep; a sleep inside an until loop is a legitimate condition poll and passes. watch and top get rejected because they never exit, which in an event-driven shell means they'd hold a promotion slot forever and never deliver a completion.
The thread underneath: fewer places to hallucinate
Around the shell work, five smaller releases kept pulling the 0.9.0 thread — closing gaps where a model could mistake silence or ambiguity for information.
Empty search results say so out loud. A search that finds nothing could just return, well, nothing — and a model handed an empty string doesn't reliably conclude "no matches." Sometimes it concludes "the tool failed" and retries; sometimes, worse, it fills the silence with what it expected to find and proceeds as if it had. So the no-match case is a sentence stating what was searched and that zero matches exist — behavior that dates back to 0.7, and that 0.10.2 pinned down with tests so it can't silently regress. Absence of evidence, stated as evidence of absence.
Tool schemas dropped their null variants (0.10.3). Optional-as-nullable in a JSON schema reads fine to a human and is an attractive nuisance to a model — "path": null is a call that validates nowhere good. Optional now means absent.
Stale line IDs report better (0.10.5). The verification errors from 0.9.0 — the ones that show fresh content and where your target moved — got more precise about both.
Listing and search got faster (0.10.1) — newline counting on raw bytes instead of lossy UTF-8 conversion, file types taken from the directory walker instead of a redundant stat per entry, and a whole-buffer prefilter that skips non-matching files before line-level work. Latency in a tool the model calls hundreds of times per session is a tax on everything.
The remote box, without ceremony
Octofs has spoken SSH/SFTP since 0.8.0 — point a tool at ssh://user@host/path and the agent gets the same verified filesystem on a remote machine. Two releases finished the job.
0.14.0 resolves targets through ~/.ssh/config. Host aliases, a ProxyJump bastion, IdentityFile, IdentityAgent, per-host users and ports — the configuration you already wrote for your own fingers now applies to the agent's connections. The test is simple: if plain ssh box works in your terminal, ssh://box/path works in octofs, bastion and all. (One hop, honestly: we reject multi-hop ProxyJump chains and ProxyCommand with a clear error rather than half-supporting them.) Before this, the agent needed the fully spelled-out target your config file exists to spare you.
0.14.1 made misuse detection read inside ssh commands. The grep-rejection story from 0.9.0 had a remote-shaped hole: ssh box 'grep -r TODO src/' sailed past a detector that respected quotes too politely to look inside them. It now parses the remote command through SSH's options and nesting and applies the same rules — the same view path="ssh://box/src" content="TODO" that replaces local grep replaces the remote one. Pipelines stay allowed, interactive SSH stays untouched.
The client side of the handshake
Everything above is one half of a protocol conversation. The other half is whatever your agent runtime does with a resource link and a resources/updated notification — and if it does nothing, background jobs silently degrade back into polling.
Our agent Octomind built its half across the same weeks: background shell jobs are tracked with a real lifecycle, survive context compaction (that's the resource link carrying the command name), and completions land in the session the moment the notification arrives. That work spanned the 0.47–0.48 cycle and is covered in the Octomind 0.48.0 release post, published this week — along with the rest of a release that went net negative by nearly ten thousand lines of production code. If you want to see what an agent looks like when the shell stops blocking it: it starts the build, edits the next file while the build runs, and reads the verdict when the verdict exists.
Nothing in octofs depends on Octomind, though. The job resources, the links, the notifications are all plain MCP — any client that follows the protocol gets the event-driven shell for free.
Upgrade
# Homebrew
brew upgrade muvon/tap/octofs
# Cargo
cargo install octofs --version 0.14.1
# npm
npm install -g @muvon/octofs
Pre-built binaries for Linux, macOS, and Windows (x86_64 and ARM64) are on the releases page.
No config changes required. One behavioral note: if your prompts or client code passed a background flag to the shell tool, remove it — the flag is gone and promotion is automatic. As with the mode switches we deleted in 0.9.0, there is nothing to replace it with; the right behavior is now the only behavior.
The difference shows up on the first command that outlasts ten seconds. Instead of a blocked session, a killed call, or a duplicate run, your agent gets its turn back, a link to the running job, and a notification when there's something worth reading.
Octofs is open source (Apache 2.0) at github.com/Muvon/octofs. The 0.9.0 post covered why a line number can't be trusted; this one covered why a blocked tool call can't be either. Same principle both times — the server's job is to hand the model something it can act on, and "wait here while nothing happens" was never that.



