8000 Restore Claude Code sessions on workspace restore (claude --resume), behind an opt-in setting · Issue #186 · amirlehmam/wmux · GitHub
[go: up one dir, main page]

Skip to content

Restore Claude Code sessions on workspace restore (claude --resume), behind an opt-in setting #186

Closed

Description

@kevmtt

What are you trying to do?

I run one Claude Code session per pane. wmux already brings back my workspaces, tabs, per-tab shell and per-tab cwd after a restart, but every restored terminal comes up as a bare shell. So I have to run claude --resume in each pane by hand and pick the right conversation out of the picker. With several workspaces open that is the most annoying part of restarting wmux.

The README states this as a deliberate non-goal:

wmux does not restore live process state. Active Claude Code, tmux, or vim sessions are not resumed after restart. Shells are respawned fresh in the saved working directories.

For a tool whose premise is a pane per agent, this is the one piece of state that actually matters.

What would you like wmux to do?

Remember which Claude Code session each terminal was running, and, behind an opt-in setting, re-launch it with claude --resume <id> when the workspace is restored.

Most of the machinery is already in the tree. SurfaceRef persists per-surface cwd, shell and startupCommands into session.json; freezeSurfaceCwds is the existing pattern for folding a live value into the saved tree at save time (#134); startupCommands already runs a command once after a restored PTY spawns; and AgentStateRecord.sessionId has carried the comment "Resumable session handle (a file/id), not a PID, survives a restart" since #128, with an RPC and a wmux report-session CLI verb to set it.

What is missing is a producer. wmux-hook.ts parses the Claude Code hook payload and throws session_id away, so that slot is always null unless an agent calls wmux report-session by hand, which nothing does. The id is never persisted, and nothing ever runs claude --resume.

Rough shape of the wiring:

  1. wmux-hook.ts forwards session_id from the hook payload as a param.
  2. handleHookEvent feeds it to the existing reportAgentSession.
  3. A new SurfaceRef.claudeSessionId is stamped into the persisted split tree at save time, the same way cwd is.
  4. On restore, a pane carrying an id appends claude --resume <id> to its startup commands. The cwd is already correct because it was frozen at save time.
  5. A new workspacePrefs.restoreClaudeSessions toggle in Settings, Workspace, off by default, since the README promises the opposite behaviour and every restored pane would otherwise launch an agent at once.

Details worth getting right:

  • Validate the id before it reaches a shell. It arrives over the named pipe and ends up on a command line. Rejecting shell metacharacters is not enough on its own: claude --resume takes an optional value, so an id like --dangerously-skip-permissions would be parsed as a flag and launch the agent with every permission prompt bypassed. A leading dash has to be refused outright.
  • Prune dead ids at load time. If the transcript under ~/.claude/projects is gone, drop the id in the main process rather than letting every pane error out on startup.
  • Leave SessionEnd alone. It already drops the record, so a Claude the user exited cleanly is forgotten and not resumed. That means the session capture has to skip that one event, or it rebuilds the record that was just released.
  • Resume once per surface per run, and spend it when a PTY is actually spawned rather than when one dies. A pane whose shell exits leaves a tab with no PTY behind it, and the next remount would otherwise start a second agent on the same conversation.
  • Do not let the id into saved layouts. A layout is captured from a live workspace and can be the default applied to every new workspace, so it would resume one conversation in every new pane.

What do you do today instead?

Run claude --resume manually in every restored pane and pick the session from the interactive list, or use wmux agent spawn --cmd "claude --resume <id>" after digging the id out of ~/.claude/projects myself.

Activity

  1. amirlehmam commented on Aug 21, 2026

    @amirlehmam
    Owner

    Implemented and shipping in v1.8.0 (release building now). d8c37fe.

    Your inventory table was right about all seven rows, including the two "missing" ones — the id really was being parsed and dropped in wmux-hook.ts, and AgentStateRecord.sessionId really was a slot nothing ever wrote. Built it as sketched, with three departures worth naming.

    Where it differs from the sketch

    Stamping is main-side, not renderer-side. You suggested mirroring freezeSurfaceCwds, which lives in split-utils.ts. The problem is that the id lives in agent-state.ts's record map, which is main-process state the renderer has never seen — so the renderer version would need a new IPC channel and a synchronised copy of main state whose only reader is the function writing it straight back out. stampClaudeSessionIds() runs in the session:save handler instead, right before anything downstream copies the tree. Same shape as the cwd freeze (pure, immutable, persisted-copy-only), just on the side that already has the data.

    SessionEnd needed an explicit exclusion, not just "stay as it is". This one bit me. You noted that SessionEnd → releaseAgent() is what makes a cleanly-exited Claude not come back, and that it "just becomes load-bearing". It's worse than load-bearing: SessionEnd carries a session_id like every other hook, and applyHookToAgentState() runs before the new reporting call in handleHookEvent(). So the naive wiring released the agent and then re-recorded the id microseconds later, from the same payload — quitting Claude and restarting wmux would have resumed the conversation you just closed. The exclusion is explicit now and commented as the thing enforcing that behaviour.

    Both validators, not one. You asked for validation before the id reaches a shell. It's enforced at reportAgentSession (the pipe boundary — anything that isn't ^[A-Za-z0-9_-]{8,128}$ is stored as null, not kept for a later caller to sanitise) and again on the renderer side before the command is built. The second check isn't belt-and-braces: the value round-trips through session.json, which is a file on disk the user can edit, so the pipe boundary isn't the last place it can become hostile.

    The rest, as specified

    • Pruning at load reads ~/.claude/projects/*/*.jsonl once for the whole restore, not per surface — create() is on the synchronous startup path that Boot performance #176 was about. Deliberately does not try to match a pane's cwd to Claude's encoded directory name: that encoding is Claude's private detail, and a moved folder would silently lose a resumable session. Existence anywhere is enough; --resume resolves the id itself. An unreadable projects dir returns "cannot tell" and keeps everything, rather than pruning every id the first time wmux runs on a machine without Claude.
    • Resume once per run is guarded in module state outside the hook instance, since the case it exists for is exactly the one where the component remounted.
    • Ordering: the resume goes last, after any quick-launch startupCommands. claude --resume takes over the terminal rather than returning to the prompt, so anything queued after it would be typed into Claude's input box instead of run by the shell.
    • Default off, in Settings → Workspace, with the README non-goal paragraph rewritten rather than deleted — the promise still holds for tmux and vim, and Claude Code is now a named, opt-in exception.

    Verification

    29 new unit tests across claude-resume.test.ts and claude-resume-command.test.ts, plus 4 added to agent-state.test.ts; 1285 green overall, tsc clean, ESLint unchanged from baseline. The injection cases are tested as cases, not asserted in a comment — x; rm -rf /, && curl … | sh, backticks, $(id), embedded newlines, path traversal.

    The one thing unit tests can't cover is the hook actually putting the field on the wire, so that's checked against the compiled dist/cli/wmux-hook.js over a real named pipe:

    method     : hook.event
    sessionId  : 11111111-2222-3333-4444-555555555555
    surfaceId  : surf-test
    tool/file  : Edit / C:/x.ts
    

    One caveat for anyone reading this before upgrading: ids are only recorded from 1.8.0 onward, so the first restore after updating still comes up as plain shells — there's nothing stamped in your current session.json yet. It works from the save after that.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      0