Skip to content

feat: fork a Claude Code session into a throwaway popup - #29

Open
ytaskiran wants to merge 13 commits into
mainfrom
feat/session-fork
Open

feat: fork a Claude Code session into a throwaway popup#29
ytaskiran wants to merge 13 commits into
mainfrom
feat/session-fork

Conversation

@ytaskiran

Copy link
Copy Markdown
Owner

What this adds

Press prefix+F in a Claude Code pane. tmux opens a popup. The popup runs a forked copy of that pane's Claude session.

The fork inherits the whole conversation. It writes to a new session id. The parent transcript stays untouched. Ask a throwaway question. Close the popup. The fork leaves nothing behind.

This solves a context problem. A side question in the main thread costs context that the real task needs. A new session loses the context that makes the question answerable.

How it works

  1. A SessionStart hook writes <session_id>\t<cwd> to ~/.config/tws/sessions/<pane_id>.
  2. The tmux binding opens a popup that runs tws fork-pane.
  3. tws fork-pane resolves its own pane, reads the pointer, validates it, then runs claude --resume <id> --fork-session.
  4. TWS_FORK=1 makes every hook inside the fork exit early. A fork must not touch its parent's files.

install.sh installs the hooks. It also offers the tmux binding as an opt-in, experimental step.

Facts we measured

We tested these before we designed the feature. They constrain the design.

Claim Evidence
--fork-session leaves the parent transcript untouched We forked an 8 KB session. The fork answered from that conversation. The parent file kept its size and its mtime.
A hook is necessary An interactive claude process shows no session id in ps. The transcript file stays closed, so lsof finds no holder.
tmux does not expand #{pane_id} in a display-popup command We tested the CLI path and the key-binding path. Both delivered the literal text to the shell. tws fork-pane therefore resolves its own pane.
Inside a popup, $TMUX_PANE is empty tmux display-message -p '#{pane_id}' returns the originating pane instead. Every hook inside a fork therefore acts on the parent pane, unless a guard stops it.
codex resume appends to the parent rollout A rollout grew from 202,443 to 255,765 bytes. tmux created no new file. Codex cannot support this feature.

Scope

Claude Code only. A Codex or Pi pane reports that forking is unsupported and launches nothing.

Error handling

Every failure prints in the popup and waits for a key. Nothing launches and nothing changes on disk.

Case Message
No agent in the pane fork: no agent running in this pane
Codex or Pi pane fork: only Claude Code supports forking — codex resume appends to the parent session
Claude pane, no pointer fork: no session recorded for this pane ...
Stale pointer fork: the recorded session no longer exists
tmux reused the pane id fork: the recorded session belongs to a different directory

The transcript check fails open. The path rule is an undocumented Claude Code internal, so a change upstream must not break the feature.

Tests

165 tests pass. The four CI jobs pass: fmt, clippy -D warnings, test, and audit.

classify is a pure function over a Facts struct, so every precondition is testable without tmux, without a filesystem, and without Claude. One test is load-bearing: it fails if somebody moves the directory check after the transcript check.

We also verified the feature against real tmux panes:

  • The popup opens through the key binding, and tws fork-pane resolves the correct pane.
  • The happy path runs --resume <sid> --fork-session, in the recorded directory, with TWS_FORK=1.
  • All five error paths print their message and launch nothing.
  • install.sh stays idempotent across three runs. The hook counts do not change and the file stays byte-identical.
  • The key-conflict check blocks bind F and bind-key -r F. It ignores a commented line.

Known limits

  • We did not verify two consecutive forks with the hooks installed in a live configuration. The defect that would have broken this is fixed and verified statically: all eight hook commands now begin with the TWS_FORK guard.
  • The conflict check does not match bind-key -N "note" F, because a multi-word -N argument breaks the flag grammar. This is a follow-up.
  • bind -T <custom-table> F counts as a conflict, although it binds another table. We chose caution over a missed conflict.

Upgrade note

The hook marker widens from config/tws/agents to config/tws/(agents|sessions). Existing installs carry the old marker only, so the first re-run strips both families and adds both back. Re-runs stay idempotent.

ytaskiran and others added 13 commits August 9, 2026 02:59
Add find_pane_agent() and agent_in_pane() to look up which agent, if any,
runs in a specific pane. Unlike scan_agents(), agent_in_pane() does not
filter by tws-session membership, allowing fork detection to work in any
pane.

- find_pane_agent(): private pure function for testability
- agent_in_pane(): public IO wrapper that queries tmux and processes

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FrL7CS8ub31uQ4MmUw7aJf
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FrL7CS8ub31uQ4MmUw7aJf
The existing cwd-mismatch test did not verify order because
transcript_exists was always true. Add a test with mismatched
pane_cwd and transcript_exists=false to ensure cwd check runs
before transcript check. Proves order is load-bearing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FrL7CS8ub31uQ4MmUw7aJf
The command reads the pane pointer file. It checks the facts. It calls
claude with --resume and --fork-session. The command sets TWS_FORK=1.
This stops the new session from overwriting the parent pointer.
Add SessionStart and SessionEnd hooks that write a pointer file at
config/tws/sessions/<pane_id>. The pointer holds the session id and
cwd, so tws fork-pane can fork the session. Widen the is_tws marker
test to match both config/tws/agents and config/tws/sessions, so
re-runs stay idempotent.
The tmux binding cannot pass a pane id. tmux does not expand
#{pane_id} inside a display-popup shell-command.

fork-pane now asks tmux for the current pane id when the caller
omits it. The manual form with an explicit pane id still works.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FrL7CS8ub31uQ4MmUw7aJf
Add configure_fork_binding to install.sh. The step asks the user
before it writes a tmux key binding. It marks the feature as
EXPERIMENTAL in the prompt and the success message. The step skips
when ~/.tmux.conf does not exist. The step warns and does not
overwrite an existing binding on prefix+F. The step is idempotent
across repeat runs.

Add a README section for the session fork feature. The section
marks the feature as EXPERIMENTAL and lists Claude Code as the
only supported agent.
The old check read only the live tmux server. A user could add
bind-key F to ~/.tmux.conf by hand and not source it yet. The old
check missed this case and let the installer add a second F
binding.

The check now also greps the config text for a bind-key F line.
It skips the block that the tws marker owns, so a re-run still
finds only its own line and stays idempotent. A commented-out
line does not count as a conflict.

Rewrite two README passages to follow the Simplified Technical
English rules: short sentences, active voice, one idea per
sentence.
One sentence in the session-fork section chained two instructions
and ended in the passive voice. Split it into three short active
sentences, one instruction each.
find_pane_agent and agent_in_pane are live code now. agent_in_pane
is called from fork.rs. The allow(dead_code) attributes hid this
fact from clippy and readers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A tmux popup reports the parent pane as $TMUX_PANE, not its own pane.
Every tws hook resolves its pane with that value, so a forked
session's own hooks were acting on the parent pane.

- Add a TWS_FORK guard to session_end_entry, and to the two agent
  status cleanup entries (Claude and Codex SessionEnd). Without the
  guard, a fork's exit deleted the parent's session pointer and
  agent status marker. This made prefix+F work only once per
  Claude session.
- Add the same guard to status_hook_entry, so a fork's own
  working/waiting/review hooks no longer overwrite the parent
  pane's status in the tws agents view.
- Widen the tmux key-conflict check to accept the bind alias (not
  only bind-key) and any leading flags (-r, -T <table>). A bind
  with -n or -T root targets the root table, not the prefix table,
  so it is excluded from the conflict check.
- Make the strip step in configure_fork_binding resilient to grep
  finding nothing to remove, so it no longer aborts the installer
  under set -o pipefail.
- Skip the prefix+F binding offer, with an explanation, when the
  Claude Code hooks were never configured. The binding is useless
  without the SessionStart hook.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merge upstream status hook changes into the session fork branch. Keep
the TWS_FORK guard on every hook command. Add the guard to upstream's
new hand-written SessionEnd commands. Widen is_tws in both hook
functions to match config/tws/sessions. Remove CLAUDE_HOOKS_CONFIGURED
and read upstream's hooks_configured flag instead.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant