Skip to content

feat(cli): let a shell outlive the turn that opened it - #275

Merged
oratis merged 1 commit into
mainfrom
feat/repl-shell-registry
Aug 15, 2026
Merged

feat(cli): let a shell outlive the turn that opened it#275
oratis merged 1 commit into
mainfrom
feat/repl-shell-registry

Conversation

@oratis

@oratis oratis commented Aug 15, 2026

Copy link
Copy Markdown
Owner

The follow-up flagged in #273. Closes the gap between "persistent shell" and what the CLI actually did with one.

The gap

#273 built the registry but left it owned by a single runAgent call, so a shell opened in one turn was closed before the next began. cd survived between two ShellRun calls inside a turn and nothing further — a slower Bash with extra steps. The point of a shell that keeps its working directory is that your next message still has it.

The REPL now owns one registry for the session and threads it through every turn, the same way it already owns the background-task manager. shells is not a HostBoundOption, so it passes through RuntimeHost.run with no plumbing.

/shells is part of this, not a follow-up

Making these processes outlive a turn is exactly what creates the need to see and stop them. Without it the user has no way to know the agent left a dev server running, or to kill it.

/shells                 # id, where it started, last used, [running]
/shells close <id>      # closes it and whatever is still running in it

Cleanup is a wrapper, not a habit

startRepl is now a thin wrapper that closes every shell in a finally, mirroring what runAgent does — so it runs when the session ends by throwing, not only on the way out of the loop. These are real processes in their own process group; they do not die with the CLI, and "the exit path will remember" is not a guarantee. It cost no re-indentation of the 190-line loop.

Sub-agents deliberately do not share them

Two agents interleaving commands in one shell would each be wrong about its state, so background tasks and delegated sub-agents get their own registry, closed when their run ends. This was already true by construction — runSubAgent builds its options from an explicit allowlist that shells is not on — but nothing pinned it, and my change is what makes it matter. There is now a test.

That test asserts the delegation actually happened before checking the registries differ. My first version of it passed new ToolRegistry([peek]), which replaces the built-ins, so Task did not exist, the parent ran the probe itself, and it failed for a reason that had nothing to do with shells. Without the delegation assertion the fixed version could have passed for the same wrong reason.

Verification

pnpm typecheck, lint, format:check, docs:check clean. Full suite green — core 1160 passed / 28 skipped (+1), cli 249 (+7), desktop 104, server 49, protocol 34, lsp 13, vscode 12, scripts 42.

Smoke-tested against the built CLI with a throwaway HOME: /shells reports no shells open (proving ctx.shells is wired rather than reporting itself unavailable), /shells close shell-1 reports the unknown id instead of claiming it closed something, and a bad argument is rejected rather than silently listing.

What is covered by construction rather than by test: "a shell survives across turns" follows from one registry being passed to every runtime.run plus the existing #273 test proving a host-owned registry is not closed by a run. Driving that end-to-end would need a real model turn, so I have not claimed a test for it.

CHANGELOG and the parity table are updated in this PR — the previous round shipped six features without either, and release notes are taken from the CHANGELOG.

The registry landed in #273 owned by a single runAgent call, so a shell opened
in one turn was closed before the next began — a slower Bash with extra steps.
The whole point of a shell that keeps its working directory is that the next
message still has it.

The REPL now owns one registry for the session and threads it through every
turn, the same way it already owns the background-task manager. `cd`, `export`,
and an activated virtualenv now survive from one message to the next.

`/shells` lists what is open and where each started; `/shells close <id>` closes
one and whatever is still running in it. That command is part of this change
rather than a follow-up: making these processes outlive a turn is exactly what
creates the need to see and stop them.

Cleanup is a wrapper, not a habit. `startRepl` closes every shell in a finally,
so it also runs when the session ends by throwing — these are real processes in
their own process group and do not die with the CLI.

Background tasks and sub-agents deliberately do not share the session's shells.
Two agents interleaving commands in one shell would each be wrong about its
state, so they get their own, closed when their run ends. A test pins that,
including an assertion that the delegation actually happened — otherwise it
would pass for the wrong reason if Task were ever missing from the registry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@oratis
oratis merged commit 14d0f7f into main Aug 15, 2026
5 checks passed
@oratis
oratis deleted the feat/repl-shell-registry branch August 15, 2026 03:47
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