Skip to content

feat(core): a shell that remembers what the last command did - #273

Merged
oratis merged 1 commit into
mainfrom
feat/persistent-shell
Aug 14, 2026
Merged

feat(core): a shell that remembers what the last command did#273
oratis merged 1 commit into
mainfrom
feat/persistent-shell

Conversation

@oratis

@oratis oratis commented Aug 14, 2026

Copy link
Copy Markdown
Owner

Sixth and last implementation PR from docs/DSH_ADOPTION_PLAN.md §1.4.

The gap

Every Bash call 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 / ShellList give 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:

  • Commands run with stdin from /dev/null. Otherwise cat swallows the sentinel and the session hangs until its deadline.
  • stderr is merged at the shell, so the two streams interleave in the order they were actually written, the way a terminal shows them.

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 30 is interrupted.

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 — 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 — runAgent wraps the run in a try/finally and 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 60 and 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 ShellOpen says 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:check clean. 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: cd persisting, export persisting, functions persisting, stderr interleaving, multi-line commands, a command printing sentinel-shaped text, cat not 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.

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
oratis force-pushed the feat/persistent-shell branch from 078ffdc to ab1b260 Compare August 14, 2026 12:58
@oratis
oratis merged commit 48872b9 into main Aug 14, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant