Skip to content

Add /fork and /rewind slash commands - #568

Draft
Aarya2004 wants to merge 16 commits into
vercel-labs:mainfrom
Aarya2004:aarya/session-fork-slash-commands
Draft

Add /fork and /rewind slash commands#568
Aarya2004 wants to merge 16 commits into
vercel-labs:mainfrom
Aarya2004:aarya/session-fork-slash-commands

Conversation

@Aarya2004

@Aarya2004 Aarya2004 commented Sep 1, 2026

Copy link
Copy Markdown

Completes #537, on top of #560.

Stacked. This builds on #560 and must merge after it. GitHub requires a base branch in this repository, so the base here is main and the diff shows #560's commits until that lands. The commits unique to this PR start at Register /fork and /rewind slash commands.

Clean diff. The true stacked view, only this PR's nine commits, is at Aarya2004#2, which is based on #560's branch inside the fork. Aarya2004#1 mirrors #560 the same way. Those mirrors are review views only; this PR is the submission.

Why

#560 added fx session fork and fx session rewind, which work from a script or a second terminal. Issue #537 asks for them where the conversation actually is, in the shell.

The two commands are not symmetric, and the asymmetry is forced by a lock. A live shell holds its own session's writer lock, so calling either store primitive on the current session fails:

$ fx session fork <live-session-id> --at 1
fx session: session is busy or the filesystem cannot provide the required lock

So /fork releases the lock and re-enters, while /rewind stays in process and rewrites what it already owns.

● Session: /rewind 2 drops the last 2 turns and leaves 1. Run /rewind 2 again to confirm. File changes are not reverted.
● Session: Rewound 2 turns; 1 turn left. File changes were not reverted.

● Session: Forked <src> at turn 2 into <dst>. You are now in the branch; <src> is unchanged.

Scope

forkLiveSession in src/core/app/app_session_runtime.zig refuses while a stream is active, validates the turn, settles the worker, calls closeWritableSession to release the lock, calls Store.forkSessionCopy, then enters the branch through the same loadResumeTargetForWrite to installResumedSession path /resume uses. Background policy is carry_forward, not stop_forget, because the source survives a fork.

rewindLiveSession does not call Store.rewindSession. This process already holds the lock, so it truncates the live history and commits through commitCurrentStateReplacement with reason .rewind. Same durable event, issued by the owner.

session.zig gains dropHistoryTurnsAfter, which owns freeing dropped turns and clamping context_history_start. session_store.zig's truncateSessionHistory now calls it, so the store's slice reallocation and the live list shrink share one rule.

RewindGate is a union of idle and armed: RewindTarget, where the target carries both history_len and retained_turns. Arming on the whole target rather than the raw count means a turn arriving between the preview and the confirmation re-arms instead of firing a rewind nobody previewed. route in command_router.zig splits into parse plus dispatch so the gate has exactly one disarm site.

Registration spans the five sites Zig's exhaustive switches enforce: SlashKind in command_specs.zig, the specs in builtins/commands.zig, ParsedCommand and CommandHandlers in command_router.zig, the handlers in app_commands.zig, and the behavior in app_session_runtime.zig.

Out of scope: the interactive turn picker. Bare /fork prints usage naming the live session id so the fx session <id> command can be pasted as written. The picker is a new alternate-screen catalog menu and belongs in its own change.

Tradeoffs

/rewind requires the identical command twice. fx has no y/n prompt and no slash command confirms today; the only precedent is the arming reducer in src/core/mcp/menu_state.zig, and this follows its shape. Any intervening command disarms.

Both rewind messages say file changes are not reverted. Claude Code's /rewind restores files through checkpoints and fx's does not, so users arriving from it will expect their working tree back. Saying so costs one clause and prevents a bad surprise.

/rewind cannot un-print. Inline rendering only appends and viewport_top_row never grows back, so dropped turns stay in scrollback above the notice. The persisted history is correct. /fork escapes this only because the resume path resets and replays.

turnPlural is duplicated, two lines, private in cli_surface.zig and app_commands.zig. Sharing it would make the app layer import the CLI surface, which is the wrong direction.

The rule that a rewind clears a paused response is stated twice: commitCurrentStateReplacement takes clear_recovery_checkpoint for the live path, and Store.rewindSession clears it directly for the CLI path. They operate on different types in different layers, so folding them together would invert the dependency. Both are covered by tests.

Blast Radius

/fork closes the live session before it can fork, so every failure after that point must leave the shell usable. All four failure sites route through one forkFailure helper that reopens the source first, and ForkOutcome is a union of branched and failed so an id and a problem cannot both be optional on one struct.

Forced at runtime by chmod 0555 on the sessions directory, which blocks the fork's staging directory while leaving the session writable:

● Session: Could not fork <id> (AccessDenied). This shell is still on <id>.

The next prompt persisted into the source and no branch was created.

Existing commands are unaffected. No public API was removed. The route split is internal to the router.

Verification

zig fmt --check src/ and zig build clean. ./scripts/check-public-surface.sh clean.

zig build test: 8647 of 8677 pass, 20 skipped, 10 failed. The failing set is byte-identical to this branch's base: six app_session_runtime session-picker and resume tests, command_effect native printf, builtins.tools terminal schema, transcript.runtime_tests tool status, and app_mcp_runtime authority reduction. None touch the slash-command chain. The new unit tests were proven to run by breaking an assertion and watching the failure count rise.

E2E: tui-session-fork.test.ts 6 pass, tui-slash-menu.test.ts 38 pass, plus tui-resize 49, tui-input-navigation / tui-render-stress / tui-gateway-stream-lifecycle 111, tui-startup / prompt-history 11, ci-shards 7.

Driven by hand against the real TUI on a fake model, through tmux. Confirmed there: the first /rewind 2 arms without executing; an intervening /version disarms so the next /rewind 2 re-arms rather than firing; two consecutive invocations execute, with an independent read-back showing history_len drop from 3 to 1; /fork 2 reports both ids and lands in the branch, with read-back showing branch [alpha, delta] against unchanged source [alpha, delta, echo]; the shell stays writable in the branch; and out-of-range arguments on both verbs name the real history length without changing the session.

Not verified: the fresh_session and no_session fork landings. Only the source landing was forced. Reaching the others needs the source reopen to fail while a fresh session can still be created, and that state could not be constructed.

tui-session-fork.test.ts is classified verification-only in scripts/pgso/corpus.json with requires_tmux: true.

This PR needs the type: feature label. I cannot apply labels on this repository.

Aarya2004 added 16 commits August 31, 2026 23:47
The staging lock, staging root, staged-session start, promotion, and
discard helpers are generic. Session fork will call them too, so the
`recovery` prefix would misdescribe them. The on-disk directory and lock
file names are unchanged.

Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD
A fork writes its history into a brand-new session, so its cache
publication cannot be deferred. A rewind rewrites the same session in
place and follows compaction.

Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD
`forkSessionCopy` stages a new session holding the first N turns of a
healthy source, copies only the artifacts those turns reach, and commits
the branch history as a `fork` state replacement. The source is opened
under its writer lock and read through the read-only replay, so it is
never rewritten.

`rewindSession` truncates the same session in place under a `rewind`
state replacement. Artifacts for the dropped turns are left on disk.

Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD
Both verbs render text and JSON from one snapshot, following the session
recovery contract.

Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD
`fork --at <turn>` names an absolute boundary read straight off the
`[turn N]` labels `fx session <id>` prints. `rewind --by <count>` names a
relative one, which is how undoing the last turns is asked for. The CLI
resolves both to the absolute retained-turn count the store takes, and
reports an out-of-range request with the session's real turn count.

Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD
The e2e suite drives the built binary against a fake gateway: it seeds a
multi-turn session, forks it, resumes the branch, and checks the source
directory is byte-identical afterward. Classified verification-only in
the PGSO corpus, since branching and undo are deliberate rare operations
that must stay correct without being made hot.

Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD
A recovery checkpoint describes an in-flight turn that sits past the last
committed one, so any rewind that drops turns leaves it describing work the
session no longer has. Keeping it let `--continue-recovery` resume a response
for a turn that was removed.

`forkSessionCopy` already cleared it. A no-op rewind still keeps it, because
that path drops nothing and commits nothing.

Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD
The specs, the parsed variants, and the handler slots land first with
stub bodies so the exhaustive switches and the registry-walking router
test prove the wiring before any behavior exists. `route` now splits into
`parse` plus `dispatch` so a caller can inspect the parsed command once
instead of parsing it twice.

Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD
A rewind runs only when the identical request is repeated. The gate arms
on the whole target, so a turn arriving between the preview and the
repeat re-arms rather than firing a rewind the user never saw described.

Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD
`dropHistoryTurnsAfter` owns freeing the dropped turns and clamping the
model-context cursor. The durable store keeps its slice reallocation and
the live runtime shrinks its list, but neither repeats the free-and-clamp
rule, so an in-place rewind cannot drift from the stored one.

Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD
`/rewind <count>` drops trailing turns from the live session. The shell
already holds the session writer lock, so the truncation happens in
process and commits through the live replacement path instead of through
`Store.rewindSession`, which would block on the lock this shell owns.

Both the confirmation and the completion say that file changes are
untouched, because the equivalent command in other agents restores files
and this one does not. Any command other than a repeated `/rewind`
disarms the gate.

Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD
`/fork <turn>` branches the live session and moves the shell into the
branch. The session is closed before the store call because
`forkSessionCopy` takes the source's writer lock, which this process
holds while a session is open. That makes every failure past the close a
shell with no session, so the source is reopened on each failing path and
the notice always says which session the shell ended up on.

Background work carries forward rather than stopping, because the source
survives a fork and its in-flight commands belong to the branch too.

Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD
Both commands accept 1 through the live history length. Holding that
rule in one tested function keeps the two ranges from drifting.

Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD
The e2e file drives the real shell through tmux: it builds turns against
the fake gateway, proves the rewind needs two identical requests and that
an intervening command cancels it, proves the fork names both ids and
that the next prompt lands in the branch while the source keeps every
turn, and reads every session back off disk afterwards. Classified
verification-only, since branching and undo are deliberate rare
operations that must stay correct without being made hot.

Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD
A successful fork always has both ids and always lands in the branch; a
failed one always has a reason and lands somewhere else. Modelling them
as one struct left `forked_id` and `problem` coupled by convention. One
`forkFailure` helper now owns the rule that every failure past the
session close reopens a session before answering, which also closes the
path where an allocation failure returned without reopening one.

Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD
Only `RewindTarget` crosses into the command layer.

Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD
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