exec+sdk: stateful shell sessions with reattach#188
Merged
Conversation
Adds `exec.shell()` to both SDKs: a long-lived bash session behind a
single exec-session ID. `cwd`, exported env vars, and shell functions
persist across `.run()` calls (terminal-tab ergonomics), and the shell
can be reattached from a different client via `exec.reattachShell(id)`.
Framing protocol: each `run()` sends bash a `{ cmd\n}` brace group
followed by a DONE marker on stdout and an EXIT marker on stderr. Both
must be observed before the run resolves — the agent reads stdout/stderr
on separate goroutines, so the stderr sentinel can otherwise land before
the stdout is drained and the user's output gets dropped.
Other fixes surfaced while bringing this up end-to-end:
- TS `Exec.attach()` awaits `ws.onopen` before returning, so the first
`sendStdin()` isn't silently dropped by the `readyState !== OPEN` guard.
- `ShellImpl` ignores inbound chunks until the worker's `0x04`
scrollback-end marker, so reattach doesn't mis-parse historical
sentinels from prior runs as a current completion.
- Python `Sandbox.exec` pairs URL + credential: direct-worker URL uses
the sandbox JWT, control-plane fallback uses the API key and includes
the `/api` prefix. Sending the JWT to the control-plane WS endpoint
yields an InvalidStatus handshake; the bare URL (no /api) yields HTTP
200 from the control plane's HTML fallback.
Docs and examples updated; new end-to-end shell tests in both SDKs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
- test step 7 now prints each stdout/stderr chunk with an arrival timestamp and gap-since-last-chunk, so callers can see exactly when bytes land instead of only that they landed. The hard streaming-span assertion is dropped — upstream hops (CF/ALB, agent→worker gRPC) may legally coalesce small frames, making the timing brittle to assert on. - Adds a standalone stream demo in both SDKs (stream_demo.py / stream-demo.ts) that simulates ~6s of apt-install-style output through shell.run() and prints per-chunk timing + a final summary (exit code, wall time, chunk counts, byte totals). Useful for eyeball verification of streaming behavior in real deployments. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…veats Adds a when-to-use-what table and rules of thumb at the top of Running Commands so readers don't have to infer the purpose of each primitive from its signature. Expands the Shell section with: - reattachShell() usage (stable sessionId across SDK invocations) - terminal-tab exit semantics (exit N closes the shell) - streaming output caveats (bash-builtin stdio buffering; upstream frame coalescing), with a pointer to the stream-demo scripts Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
Summary
exec.shell()on both TS + Python SDKs: a long-lived bash exec session with a stablesessionId, wherecwd, exported env vars, and shell functions persist across.run()calls. Terminal-tab ergonomics, single server-side process.exec.reattachShell(sessionId)/exec.reattach_shell(session_id): revisits an open shell from a different client (or a different process invocation). No backend changes required — the worker already sends0x04scrollback-end markers and exec sessions survive client disconnect.run()framing uses dual sentinels: a DONE marker on stdout and an EXIT marker on stderr. A run resolves only once both markers are observed. Without this, the stderr sentinel frequently lands before the user's stdout is drained (stdout/stderr are read on separate goroutines in the agent) and the trailing stdout is silently dropped into the next run.ShellImplgates on scrollback completion: inbound chunks before the0x04marker are ignored, so reattaching to a shell that has prior runs in its scrollback doesn't mis-parse old sentinels as a current completion.Incidental fixes found while wiring this up
Exec.attach()now awaitsws.onopenbefore returning. The previous code returned while the WS was still CONNECTING, and the firstsendStdin()was silently dropped by thereadyState !== OPENguard insendStdin.{ cmd\n; }→{ cmd\n}). The stray;after the newline was producingbash: syntax error near unexpected token ';'and bash exited with code 2 on the first run.Sandbox.execnow pairs URL + credential correctly. Previously the property passed the sandbox JWT to whichever URL was available; when falling back to_api_url(control plane), the JWT was rejected with anInvalidStatushandshake. Control-plane requests now use the API key and include the/apiprefix.Terminal-tab semantics
exit Ninside arun()kills the shell — same as closing a terminal tab — and rejects the pending run withShellClosedError. Tests updated to match (step 5 originally assertedexit 42 || true; ( exit 7 )returned 7; now uses a subshell so the outer shell stays alive).Test plan
npx tsx sdks/typescript/examples/test-shell.ts(hitsapp.opencomputer.dev): all assertions pass through step 15, including reattach preserving cwd/env andexit Nclosing the shell.python sdks/python/examples/test_shell.py: same assertions, Python side.sandbox.exec.list()still sees the shell session alongside regular exec sessions;shell.sessionIdis the same ID.exec.run()/exec.start()/exec.background()behavior unchanged.🤖 Generated with Claude Code