feat(core): a shell that remembers what the last command did - #273
Merged
Conversation
Every Bash call is a fresh process, so cd, export, and source venv/bin/activate are forgotten the moment they return. The model's workaround is re-pasting the whole prefix into every command — long, easy to get wrong, and still unable to hold a background server. ShellOpen/ShellRun/ShellClose/ShellList give it a shell that stays alive, keeping its working directory, environment, functions, and background jobs. Not a PTY. dsh uses one, which means node-pty: a native dependency needing a build for every platform the desktop ships to. That is a real release risk for a benefit — full-screen programs like vim and top — that is a small part of what makes a persistent shell useful. This runs over ordinary pipes and detects completion with a per-session random sentinel. Full-screen programs do not work, and the tool description says so rather than letting the model discover it. Two pipe consequences, both deliberate. Commands run with stdin from /dev/null, because otherwise `cat` would swallow the sentinel and hang the session until its deadline. And stderr is merged at the shell, so the streams interleave in the order they were actually written. Interrupting a command signals the shell's children, not its process group. Signalling the group takes the shell with it — a non-interactive bash dies on SIGINT — so the session would be lost every time a command overran. Signalling only the children stops the command, bash reports 130, and the state survives. `exit` still closes the shell, because that is what `exit` does. The caller is told the shell is gone rather than handed a dead id. Lifetime is guaranteed, not remembered. A run that opens its own registry closes every shell before returning, including when the loop throws — the wrapper is there so a crashed run cannot leave shell processes on the machine. Idle shells close themselves, and there is a cap on how many can be open. A host that owns its own registry keeps its shells across runs. Sandbox confinement is resolved once, when the shell starts; a later settings change does not re-arm a running shell, and ShellOpen says so in its result. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
oratis
force-pushed
the
feat/persistent-shell
branch
from
August 14, 2026 12:58
078ffdc to
ab1b260
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Sixth and last implementation PR from
docs/DSH_ADOPTION_PLAN.md§1.4.The gap
Every
Bashcall is a fresh process.cd,export,source venv/bin/activate— all forgotten the moment it returns. The model's workaround is re-pasting the whole prefix into every command: long, easy to get wrong, and still unable to hold a running dev server.ShellOpen/ShellRun/ShellClose/ShellListgive it a shell that stays alive, keeping its working directory, environment, shell functions, and background jobs.Deliberately not a PTY
dsh uses one. A PTY means
node-pty— a native dependency needing a build for every platform the desktop ships to, which is a real release risk for a Mac-first product. The benefit it buys (full-screen programs: vim, top, less) is a small share of what makes a persistent shell useful.So this runs over ordinary pipes, detecting command completion with a per-session random sentinel. Full-screen programs do not work, and
ShellOpen's description says so plainly rather than letting the model find out by hanging.Two consequences of that choice, both deliberate and both tested:
/dev/null. Otherwisecatswallows the sentinel and the session hangs until its deadline.Interrupting without losing the shell
The first implementation signalled the process group. That kills the shell too — a non-interactive bash terminates on SIGINT — so every overrunning command would have destroyed the session. I probed the actual behaviour rather than guessing: signalling only the shell's children stops the command, bash reports exit 130, and the session survives with its state intact. There is a test asserting a shell is still usable, and still holds its variables, after a
sleep 30is interrupted.exitstill closes the shell, because that is whatexitdoes. The caller is told the shell is gone rather than handed a dead id — there is a test pinning that, since it is the sort of thing one is tempted to paper over.Lifetime is guaranteed, not remembered
The opponent case was leaks: a crashed session leaving orphaned shells on the machine. So the guarantee does not rest on the loop reaching its normal exit —
runAgentwraps the run in atry/finallyand closes every shell it opened even when the loop throws. There is a test that captures the registry from inside a tool, explodes the provider, and asserts the registry is empty afterwards.Beyond that: idle shells close themselves (30 min, on an unref'd timer so an idle shell never holds the process open), there is a cap on how many can be open at once, and closing a shell kills what it started — tested by backgrounding a
sleep 60and asserting its pid is gone afterwards.A host that owns its own registry (a REPL session) keeps its shells across runs; that path is tested too.
Sandbox
Confinement is resolved once, when the shell starts, and
ShellOpensays so in its result. A later settings change does not re-arm a running shell — stating that is better than a policy that silently means something different than the user thinks.Verification
pnpm typecheck,lint,format:checkclean. Full suite green — core 1066 passed / 28 skipped, cli 242, desktop 104, server 49, protocol 34, lsp 13, vscode 12, scripts 42.32 new tests against real shell processes, not mocks:
cdpersisting,exportpersisting, functions persisting, stderr interleaving, multi-line commands, a command printing sentinel-shaped text,catnot stealing the sentinel, interrupt-and-survive, child cleanup on close, the idle timeout and its reset, and the concurrency cap.Not included
Wiring a session-scoped registry into the CLI REPL, so shells survive between turns rather than between tool calls within one run. The capability and its lifetime guarantees land here; the REPL ownership is a small follow-up that touches a different surface.