Add /fork and /rewind slash commands - #568
Draft
Aarya2004 wants to merge 16 commits into
Draft
Conversation
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
This was referenced Sep 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
mainand the diff shows #560's commits until that lands. The commits unique to this PR start atRegister /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 forkandfx 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:
So
/forkreleases the lock and re-enters, while/rewindstays in process and rewrites what it already owns.Scope
forkLiveSessioninsrc/core/app/app_session_runtime.zigrefuses while a stream is active, validates the turn, settles the worker, callscloseWritableSessionto release the lock, callsStore.forkSessionCopy, then enters the branch through the sameloadResumeTargetForWritetoinstallResumedSessionpath/resumeuses. Background policy iscarry_forward, notstop_forget, because the source survives a fork.rewindLiveSessiondoes not callStore.rewindSession. This process already holds the lock, so it truncates the live history and commits throughcommitCurrentStateReplacementwith reason.rewind. Same durable event, issued by the owner.session.ziggainsdropHistoryTurnsAfter, which owns freeing dropped turns and clampingcontext_history_start.session_store.zig'struncateSessionHistorynow calls it, so the store's slice reallocation and the live list shrink share one rule.RewindGateis a union ofidleandarmed: RewindTarget, where the target carries bothhistory_lenandretained_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.routeincommand_router.zigsplits intoparseplusdispatchso the gate has exactly one disarm site.Registration spans the five sites Zig's exhaustive switches enforce:
SlashKindincommand_specs.zig, the specs inbuiltins/commands.zig,ParsedCommandandCommandHandlersincommand_router.zig, the handlers inapp_commands.zig, and the behavior inapp_session_runtime.zig.Out of scope: the interactive turn picker. Bare
/forkprints usage naming the live session id so thefx session <id>command can be pasted as written. The picker is a new alternate-screen catalog menu and belongs in its own change.Tradeoffs
/rewindrequires the identical command twice. fx has no y/n prompt and no slash command confirms today; the only precedent is the arming reducer insrc/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
/rewindrestores 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./rewindcannot un-print. Inline rendering only appends andviewport_top_rownever grows back, so dropped turns stay in scrollback above the notice. The persisted history is correct./forkescapes this only because the resume path resets and replays.turnPluralis duplicated, two lines, private incli_surface.zigandapp_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:
commitCurrentStateReplacementtakesclear_recovery_checkpointfor the live path, andStore.rewindSessionclears 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
/forkcloses the live session before it can fork, so every failure after that point must leave the shell usable. All four failure sites route through oneforkFailurehelper that reopens the source first, andForkOutcomeis a union ofbranchedandfailedso an id and a problem cannot both be optional on one struct.Forced at runtime by
chmod 0555on the sessions directory, which blocks the fork's staging directory while leaving the session writable:The next prompt persisted into the source and no branch was created.
Existing commands are unaffected. No public API was removed. The
routesplit is internal to the router.Verification
zig fmt --check src/andzig buildclean../scripts/check-public-surface.shclean.zig build test: 8647 of 8677 pass, 20 skipped, 10 failed. The failing set is byte-identical to this branch's base: sixapp_session_runtimesession-picker and resume tests,command_effectnative printf,builtins.toolsterminal schema,transcript.runtime_teststool status, andapp_mcp_runtimeauthority 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.ts6 pass,tui-slash-menu.test.ts38 pass, plustui-resize49,tui-input-navigation/tui-render-stress/tui-gateway-stream-lifecycle111,tui-startup/prompt-history11,ci-shards7.Driven by hand against the real TUI on a fake model, through tmux. Confirmed there: the first
/rewind 2arms without executing; an intervening/versiondisarms so the next/rewind 2re-arms rather than firing; two consecutive invocations execute, with an independent read-back showinghistory_lendrop from 3 to 1;/fork 2reports 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_sessionandno_sessionfork landings. Only thesourcelanding 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.tsis classified verification-only inscripts/pgso/corpus.jsonwithrequires_tmux: true.This PR needs the
type: featurelabel. I cannot apply labels on this repository.