Skip to content

fix(shell): key PTY sessions by client identity and pre-assign Claude session ids - #1005

Open
mayankdebnath wants to merge 1 commit into
siteboon:mainfrom
mayankdebnath:fix/shell-pty-session-identity
Open

fix(shell): key PTY sessions by client identity and pre-assign Claude session ids#1005
mayankdebnath wants to merge 1 commit into
siteboon:mainfrom
mayankdebnath:fix/shell-pty-session-identity

Conversation

@mayankdebnath

@mayankdebnath mayankdebnath commented Jul 12, 2026

Copy link
Copy Markdown

Summary

Fixes #1004: the /shell PTY registry keyed sessions as
${projectPath}_${sessionId ?? 'default'}, which conflated distinct logical
shells and lost track of a conversation once its session id materialized. The
result was silent cross-tab hijack ("you think you're looking at your shell but
you're attached to a different process") and duplicate claude --resume
processes with the original left running — burning tokens on output nobody sees
— until the 30-minute reaper.

The two failure modes (details in #1004)

  1. Cross-tab hijack. Every new-session shell in a project collapsed to one
    _default key, so a second tab silently attached to the first tab's
    process. Both terminals then drove the same PTY while only the newest
    rendered output.
  2. Duplicate process + orphan. A shell started as "new" lived under
    _default; opening the same conversation by its session id later produced a
    different key, so the server spawned a second claude --resume <id> while
    the original process kept running under _default.

The fix

  • Per-tab shell identity. The client sends a stable shellClientId
    (persisted in sessionStorage, so two tabs are distinct but a remount in the
    same tab reuses it). New-session keys include it — distinct tabs now get
    distinct PTYs; the same tab still reattaches. Session-keyed shells ignore it,
    so cross-device handoff to a resumed session is unchanged. Clients that don't
    send the field keep today's exact behavior.
  • Pre-assigned Claude session ids. Brand-new Claude sessions launch as
    claude --session-id <server-generated uuid> and register a
    sessionId → ptyKey alias. A later init that opens the conversation by id
    resolves through the alias and reattaches to the same PTY instead of
    forking a duplicate resume.
  • Explicit takeover. Attaching to a PTY whose socket is still open now
    notifies the old client (yellow banner + structured session_detached
    message) before rebinding, instead of stealing it silently — and a detached
    socket can no longer write into or resize the shared PTY.
  • Stale-close guard. A late close from a superseded socket no longer
    detaches the live client or arms a 30-minute kill timer against the PTY a
    newer socket owns (this is the "stale close detaches the live PTY" half of
    Shell terminal silently freezes on mobile while Chat keeps updating — stale WebSocket close detaches the live PTY socket; half-open sockets never reaped #953; the half-open socket reaping from Shell terminal silently freezes on mobile while Chat keeps updating — stale WebSocket close detaches the live PTY socket; half-open sockets never reaped #953 is intentionally out of scope
    here). The reaper timer is also cleared before being rearmed.

Key resolution and alias routing are extracted as pure exported functions
(resolvePtySessionKey, resolveSessionAlias) with unit tests.

Verification

  • Live end-to-end against the built server with concurrent websocket
    clients (register → two /shell sockets → init):
    • two tabs with distinct shellClientIds → separate PTYs (no
      "[Reconnected to existing session]" hijack) ✅
    • two legacy clients without the field → reattach behavior preserved
    • the superseded client receives the detach banner and structured
      session_detached message
    • input from the detached client no longer reaches the shared PTY
  • Claude CLI behavior confirmed locally (Claude Code v2.1.206):
    claude --session-id <uuid> -p … creates exactly <uuid>.jsonl, and a
    subsequent claude --resume <uuid> continues the same session file — so
    pre-assignment + later reattach cannot fork the conversation.
  • 14 new unit tests (node:test) for key resolution, alias routing, and
    --session-id command building across platforms and providers; full suite,
    npm run lint, npm run typecheck, and npm run build all pass.

Hardening found during self-review

While stress-testing the edge cases before opening this, I found and fixed
three interactions in the initial version of this change:

  • Restart of an alias-routed conversation: the force-restart path killed
    the aliased PTY but respawned it under the previous tab's identity key,
    which would have re-created the duplicate/orphan problem after a restart.
    The registry key is now recomputed after the kill so the restarted PTY
    lands under its canonical key.
  • Old CLIs: --session-id was added to the Claude CLI in mid-2025; older
    binaries exit with error: unknown option. New-session launches now carry
    the same || claude / $LASTEXITCODE fallback the resume path already
    uses. (On such CLIs the pre-assigned id never exists, so its alias is
    unreachable and simply ages out.)
  • Superseded client UX: the client now reacts to session_detached by
    closing its socket, so the losing window drops into the normal disconnected
    state (with the reconnect affordance) instead of looking attached while the
    server discards its input.

Known limitations (unchanged from today's behavior)

  • A tab created via "Duplicate tab"/window.open clones sessionStorage, so
    it shares its source tab's identity and degrades to the current shared-PTY
    behavior — now with an explicit detach notice rather than a silent steal.
  • Opening "new session" again in the same tab while a previous new-session
    shell is still alive reattaches to that shell (same as today's _default
    behavior); distinct tabs are the case this PR separates.

Compatibility notes

Summary by CodeRabbit

  • New Features

    • Improved shell session continuity across reconnects and browser tabs.
    • Added automatic session handoff when a shell is reopened or reattached.
    • Added support for preserving new Claude sessions with stable session identities.
    • The interface now detects when a session is detached and updates its connection state.
  • Bug Fixes

    • Prevented input, resizing, and disconnect events from affecting the wrong shell session.
    • Improved cleanup of closed or stale shell sessions.

… session ids

The PTY registry keyed shells as `${projectPath}_${sessionId ?? 'default'}`,
which conflated distinct logical shells and lost track of a session once its
id materialized:

- Every new-session shell in a project collapsed to one `_default` key, so a
  second tab silently attached to the first tab's process ("[Reconnected to
  existing session]") — both terminals drove the same PTY while only the
  newest rendered output.
- When the same conversation was later opened by its session id, the key no
  longer matched, so the server spawned a duplicate `claude --resume` while
  the original process kept running (and consuming tokens) under `_default`
  until the 30-minute reaper fired.

Fix:
- The client sends a stable per-tab `shellClientId` (sessionStorage-persisted,
  so tabs are distinct but a remount in the same tab still reattaches). New-
  session keys include it; session-keyed shells ignore it so cross-device
  handoff is unchanged. Clients that don't send the field keep the legacy key.
- Brand-new Claude sessions launch with a pre-assigned `--session-id <uuid>`
  and register a sessionId -> ptyKey alias, so a later by-id open reattaches
  to the same PTY instead of forking a duplicate resume.
- Attaching to a PTY whose socket is still open notifies and detaches the old
  client explicitly (banner + `session_detached` message) instead of stealing
  it silently, a detached socket can no longer write into or resize the shared
  PTY, and the superseded client closes its socket on `session_detached` so its
  UI drops into the normal disconnected state instead of eating keystrokes.
- Restarting a shell (forceRestart/login) recomputes the registry key after the
  kill, so a restarted conversation is re-registered under its canonical key
  rather than stranded under the previous tab's identity key.
- `--session-id` launches carry a plain-`claude` fallback (mirroring the resume
  fallback) so CLIs that predate the flag still start.
- A stale socket close no longer detaches the current client or arms a kill
  timer against a PTY a newer socket owns (the detach half of siteboon#953), and the
  reaper timer is cleared before being rearmed.

Verified end-to-end against the built server with two concurrent websocket
clients: distinct tabs now get separate PTYs, legacy clients keep the reattach
behavior, the old client receives the detach notice, and detached input is
blocked. Also verified with Claude Code v2.1.206 that `--session-id <uuid>`
creates exactly that session and `--resume <uuid>` continues the same file.

Fixes siteboon#1004
@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: a5e5f3ef-3b18-4167-8d95-60e8edaeb9a3

📥 Commits

Reviewing files that changed from the base of the PR and between 5884573 and 316abb6.

📒 Files selected for processing (4)
  • server/modules/websocket/services/shell-websocket.service.ts
  • server/modules/websocket/tests/shell-session-identity.test.ts
  • src/components/shell/hooks/useShellConnection.ts
  • src/components/shell/types/types.ts

📝 Walkthrough

Walkthrough

The PR adds stable per-tab shell identities, deterministic PTY routing, Claude session aliases, explicit websocket detachment, and ownership-aware cleanup. It also extends shell message types and adds tests for routing and command construction.

Changes

Shell session identity

Layer / File(s) Summary
Shell identity handshake
src/components/shell/hooks/useShellConnection.ts, src/components/shell/types/types.ts, server/modules/websocket/services/shell-websocket.service.ts
The client persists and sends shellClientId; message types include initialization identity and session_detached handling.
PTY routing and Claude session aliases
server/modules/websocket/services/shell-websocket.service.ts, server/modules/websocket/tests/shell-session-identity.test.ts
PTY keys include valid per-tab identities, new Claude sessions receive pre-assigned IDs, aliases route later session-id opens to live PTYs, and helper tests cover these behaviors.
Socket takeover and PTY cleanup
server/modules/websocket/services/shell-websocket.service.ts
Reattachments notify previous sockets, input and resize require ownership, stale closes are ignored, and teardown removes PTYs and aliases consistently.

Sequence Diagram(s)

sequenceDiagram
  participant ShellHook
  participant ShellWebSocketService
  participant ClaudePTY
  ShellHook->>ShellWebSocketService: init with shellClientId or sessionId
  ShellWebSocketService->>ShellWebSocketService: resolve PTY key or session alias
  ShellWebSocketService->>ClaudePTY: launch or reattach Claude PTY
  ClaudePTY-->>ShellWebSocketService: assigned session output
  ShellWebSocketService-->>ShellHook: output or session_detached
Loading

Possibly related issues

  • #953 — The ownership checks and stale-close handling directly address its websocket teardown race.

Possibly related PRs

Suggested reviewers: viper151

Poem

A bunny hops through PTY keys,
With Claude IDs in tidy seas.
Old sockets wave and then depart,
New tabs keep their own small heart.
No stale close can spoil the start.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the core shell PTY identity and Claude session ID changes.
Linked Issues check ✅ Passed The changes implement stable shell client IDs, alias-based reattachment, explicit takeover, and stale-close protection required by #1004.
Out of Scope Changes check ✅ Passed All modified files are directly tied to shell PTY identity, session handoff, and supporting tests/types.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mayankdebnath

Copy link
Copy Markdown
Author

Provider-scope note, for reviewers weighing how much of #1004 this covers per provider:

  • The per-tab identity keying is provider-neutral — new-session shells for Cursor, Codex, and opencode also stop colliding on the shared default key, and the explicit-takeover/stale-close guards apply to all of them.
  • The session-id pre-assignment (the part that kills the duplicate-resume fork) is Claude-only in this PR, because only Claude's CLI can pin the id at launch (--session-id <uuid>).
  • Cursor could gain the same parity later: cursor-agent create-chat returns a new chat id before launch, so the alias could be registered and the shell started with --resume=<id> — happy to do that as a follow-up if wanted.
  • Codex and opencode structurally can't pre-assign (no session-id flag; ids are generated internally and resume requires an existing recorded session), so their new-session shells rely on the client-identity keying alone, and by-id reattach for them still depends on the existing post-hoc session discovery.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant