Repository navigation
Description
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:
wmux-hook.tsforwardssession_idfrom the hook payload as a param.handleHookEventfeeds it to the existingreportAgentSession.- A new
SurfaceRef.claudeSessionIdis stamped into the persisted split tree at save time, the same way cwd is. - 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. - A new
workspacePrefs.restoreClaudeSessionstoggle 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 --resumetakes an optional value, so an id like--dangerously-skip-permissionswould 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/projectsis gone, drop the id in the main process rather than letting every pane error out on startup. - Leave
SessionEndalone. 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
amirlehmam commentedon Aug 21, 2026
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, andAgentStateRecord.sessionIdreally 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 insplit-utils.ts. The problem is that the id lives inagent-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 thesession:savehandler 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.SessionEndneeded an explicit exclusion, not just "stay as it is". This one bit me. You noted thatSessionEnd→releaseAgent()is what makes a cleanly-exited Claude not come back, and that it "just becomes load-bearing". It's worse than load-bearing:SessionEndcarries asession_idlike every other hook, andapplyHookToAgentState()runs before the new reporting call inhandleHookEvent(). 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 asnull, 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 throughsession.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
~/.claude/projects/*/*.jsonlonce 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;--resumeresolves 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.startupCommands.claude --resumetakes 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.Verification
29 new unit tests across
claude-resume.test.tsandclaude-resume-command.test.ts, plus 4 added toagent-state.test.ts; 1285 green overall,tscclean, 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.jsover a real named pipe: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.jsonyet. It works from the save after that.chore(resources): resync the checked-in wmux-hook.js
feat(resume): reopen Antigravity CLI (agy) conversations per tab
feat(resume): reopen Antigravity CLI (agy) conversations per tab
feat(resume): reopen Antigravity CLI (agy) conversations per tab (#307)