feat(dev): PTY-backed Claude Code / Codex host sessions in the Workbench (#600 PR 3) - #675
Merged
Conversation
…n-fit for the Workbench (#600 PR 3)
…_SESSION (#600) Wrappers post a top-level receipt `devSession` and leave the host session_id on identity. The proxy sends `x-agent-bundle-dev-session` when the id is valid; receipts reject a bad value with AB8266 and call `attachHostSession`. Host MCP opens with a lazy sessionId resolver so frames pick up the host id once known.
… the Sessions pane traceSources gains 'session'; the Trace page headlines a host-session group by its lifecycle entry (glyph ▣) and offers a Session link back to /sessions?session=<hs id> whenever a group is joined on an hs_ session id.
…ne, Open in Claude/Codex Consume the frozen /api/sessions HTTP+SSE contract: strict zod decoders, base64 output frames written to one xterm per session, input/resize posts, Host sessions nav item with /sessions?session=<id> deep links, Trace link per session (traceSessionId ?? id), and Open in Claude / Open in Codex beside Run with the seeded prompts, disabled with the availability reason. Adds the sessions-* / route-open-in-* test ids and the xterm.js MIT notice.
…utes; share isHostSessionId
…he Workbench shim
…cestry — Codex forwards only a whitelisted env to stdio MCP servers
…event carries change flags)
…ng, and the stream replay buffer
🦋 Changeset detectedLatest commit: 71149d8 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
commit: |
…receipt by shape, replay scrollback as one frame, re-attach on a new host session id
This was referenced Sep 6, 2026
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.
PR 3 of #600 (owner decision 05:31: go-full). PTY-backed Claude Code / Codex sessions embedded in the Workbench, attached to the dev plugin through the existing dev install / epoch mechanism, correlated into the PR 2b trace.
What lands
packages/agent-bundle/src/dev/sessions/):HostSessionService(PTY allocation via@lydell/node-pty, loaded lazily from the project root; max 4 live sessions; 256 KiB scrollback; terminate = SIGTERM then SIGKILL after 2 s; restart carriesrestartOfand the prompt) andHostSessionRoutesunder/api/sessions— list, create, get, SSE stream (state/ base64output/end), input, resize, terminate, restart, delete — every route behind the same mutation-session authority as/api/invocations, loopback only. Fixed argv (claude [prompt],codex [prompt]), cwd = project root, no approval-bypass flag is ever passed. A host is launchable only while its dev install (--install-host) is attached to the adopted epoch. DiagnosticsAB8260–AB8266.sessionIdjoin key as soon as its first hook receipt arrives. The generated wrapper addsdevSession(fromAGENT_BUNDLE_DEV_SESSION) to the receipt, the server records the alias, publishessession.attached, and later lifecycle entries carry the host's id. The dev proxy sendsx-agent-bundle-dev-session(whenAGENT_BUNDLE_DEV_SESSIONreaches it) and alwaysx-agent-bundle-dev-pid; Codex forwards only a whitelisted env to stdio MCP servers, soHostSessionRoutesfalls back to resolving the Workbench session from the proxy's process ancestry (HostSessionService.sessionForProcess). The MCP trace sink resolves the alias lazily per frame. New trace sourcesession.Host sessionsdestination (/sessions?session=<id>), xterm.js terminal (@xterm/xterm+ fit addon, MIT, bundled by the Workbench Rsbuild config), launch buttons with availability reasons, authority strip (project root, epoch, install destination, pid, state), Terminate / Restart / Forget,Open in Claude/Open in Codexon route workspaces seeding a prompt, Trace links both ways.@lydell/node-pty@1.2.0-beta.15(MIT, prebuilt) is a root workspace devDependency — never named inpackages/agent-bundle/package.json, never in a plugin artifact. Consumers add it to their own dev deps to enable Sessions; without itAB8260disables launch and nothing else changes.dev-server-http.mdx(/api/sessions*, SSE frames,sessionsource),runtime-environment.mdx(AGENT_BUNDLE_DEV_SESSION),docs/diagnostics.md.Security posture
No remote PTY exposure: the routes bind on the foreground server's loopback listener and require its mutation session (cookie/bearer + browser-origin check). Launch is an explicit
POST /api/sessionswith a fixed host and optional prompt; the request never carries a command, argv, cwd, or env. The host's own permission/consent flow is untouched. The PTY environment is the dev server's environment plusAGENT_BUNDLE_DEV_TRACE_URL/TOKEN(already the hook-receipt contract) andAGENT_BUNDLE_DEV_SESSION; the foreground session token is never passed to the child. Project-root authority (path, epoch, install destination, pid) is shown in the Sessions pane. Trace entries carry ids and byte counts, never prompt text, PTY bytes, or credentials.Acceptance
Deterministic (CI):
packages/workbench/tests/sessions.e2e.test.ts(integration pool, 1440×900) runs a copy ofexamples/audiobook-curatorwith the dev server started withinstallHosts: ['claude','codex']under an isolatedHOME/CLAUDE_CONFIG_DIR/CODEX_HOME,PATHprefixed withpackages/agent-bundle/tests/support/fake-host-cli/(dependency-free Node scripts that behave like a host attached to the dev install: answerplugin list --json/marketplace add, run the installed plugin'sSessionStarthook command fromhooks.jsonwith a host-shaped payload, spawn the.mcp.jsondev proxy and driveinitialize→tools/list→tools/call; the Codex fake forwards only the host's whitelisted env, so its correlation is proven through proxy PID ancestry). Asserts: launch Claude → banner in the terminal → authority strip shows project root / epoch / install / pid →traceSessionIdbecomes the host's own session id → Trace deep link lands on one group containingsession.*,hook.received, andtools/callentries → group header links back to the session; terminate →Terminated; restart → new id withRestart of <old>; the same for Codex;Open in Claudefrom/routes/mcp/curator/tool/search_audiblelands on Sessions with the seeded prompt in the terminal; an unauthenticatedPOST /api/sessionsfrom the page is refused; hosts offPATHdisable launch with the reason shown.Real hosts (this machine,
examples/audiobook-curator, 1440×900): launched the realclaudethrough the pane; terminal renders Claude Code inside the Workbench; authority strip shows the project root, epoch and the dev install path; Terminate →TERMINATED · EXIT 143; Restart → new session withRestart of hs_…; the Trace showssession.started/session.terminated/session.started(restartOf)correlated on the session id; the PTY child carriesAGENT_BUNDLE_DEV_SESSION=<hs_…>and the existingAGENT_BUNDLE_DEV_TRACE_URL/TOKEN, not the foreground token; no approval-bypass flag is passed (the "bypass permissions" banner in the screenshot is this machine's own~/.claude/settings.jsondefaultMode). Running a tool through the real hosts is blocked here:claude auth status→loggedIn: false, andcodexis out of usage — an owner-side credential gap, covered until then by the fake-host acceptance and the opt-inAGENT_BUNDLE_NATIVE_HOST_CONTRACTS=1 packages/agent-bundle/tests/native-host-sessions.test.ts.Found during acceptance, pre-existing and out of scope: restarting
agent-bundle dev --install-host …after a dev install exists failsAB7202/AB7004because the installer's compare refuses the generation symlink layout (Claude) and Codex refuses re-adding the marketplace from a new temp source — filed as a follow-up on #600.Local gate (merge gate)
Run on
wb600-pr3-sessionsat71149d85c2(the final commit, containingorigin/mainc864e11a82#674):pnpm buildpnpm typecheckpnpm lintpnpm test:unitpnpm test:route-unitpnpm test:projectionpnpm test:integration:run(whole pool incl. the Workbench browser pool and the newsessions.e2e.test.ts)pnpm test:packedpnpm docs:site:buildThe same gate ran green on the pre-review commit
cb4d1f2045(theretest:packedfirst hit one 30 s timeout in the untouchedpublic-api-packed.test.ts—npm installunder load, no assertion reached — and passed on re-run). The diff touches the hook wrapper (events/trace-receipt.ts), the dev proxy, the foreground server, the Workbench and the docs, hence the whole integration pool, the packed pool and the docs site.Deslop
Model: Claude Fable 5.1, 5 edits over the full diff vs
origin/main: dropped theagent-bundle/contracts/host-sessionspublic entry S1 had added (no consumer — the Workbench imports contracts by relative source path like every other contract, and no other contract is a public entry); removed the unused disposer plumbing from the PTY adapter (PtyProcess.onData/onExitnow returnvoid; the fakes followed); replaced the SSE stream's replay buffer with an existence check before the head is written; usedHOST_SESSION_UNKNOWN_CODEinstead of a repeated'AB8262'literal; deleted the lane notes file S4 committed. Behavior unchanged.Self-review
Reviewer:
change-risk-revieweron Claude Fable 5.1 (thinking-high), full diff vsorigin/main. Round 1: no blockers; 3 should-fix, 3 nits.promptis an unconstrained argv element —--dangerously-skip-permissionsor a subcommand word would be parsed by the host CLI, contradicting the docsa34bf5e223):POST /api/sessionsrefuses a blank prompt or one starting with-(AB8261), test added; docs state the prompt is the host's positional argument. Subcommand-shaped first words are not rejected — a real prompt may start withmcp, and the caller is the authenticated local developerAGENT_BUNDLE_DEV_SESSION; the server 400s non-hs_values, so a stray shell variable would silently drop every hook receiptdevSessiononisHostSessionId, like the proxy; test addedoutputframe (Buffer.concat); service test and en/zh reference updatedattach()was first-wins; Claude Code issues a newsession_idon/clear, splitting the Trace groupsession.attached; docs mention/clearand pre-receipths_…groupingdevSession; every receipt doesworkbench-server.tscloses overhostSessions~200 lines before itsconst(TDZ if invoked early)hookReceiptUrlset afterlisten, i.e. after everyconstin the function is initialised — the same shape the file already uses forhookReceiptUrlResidual risks the reviewer noted, accepted:
x-agent-bundle-dev-session/-pidon/mcp/host/*are loopback client claims (a local process could mis-attribute its own MCP frames to a session in the Trace; no PTY access or privilege —/mcp/host/*already trusted loopback callers before this PR). Round 2 (same reviewer, on the fix commit): findings 1–3, 5 resolved; 6 dismissal accepted; no new blockers. One new should-fix on 4: last-wins on every receipt would let a nested host run inside the PTY (claude -p …from a Bash tool, the plugin's own tests) flip the alias on each tool hook. Fixed (71149d85c2): the alias moves only on asession/startreceipt, so/clearis followed and a nested run can move it at most once; test with an interleavedtool/beforereceipt carrying another id; docs say "asession/startreceipt with a new host id". The final gate below ran on that commit.Closes #600 after merge (landing summary posted there).