Skip to content

feat: go back a turn — take back messages on any backend, optionally restoring the files (#355) - #362

Merged
saucam merged 4 commits into
mainfrom
feat/rewind-355
Oct 10, 2026
Merged

saucam merged 4 commits into
mainfrom
feat/rewind-355

Conversation

@saucam

@saucam saucam commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #355 · Builds on #361 (#354, merged).

What this does

You can now take back a message, or go back to any earlier point in a session, on any backend.
The agent forgets what was taken back, and the message comes back to your input box to edit and resend.
Optionally, the files go back too, to exactly how they were when that message was sent.

  • Where:
    • Web: an "↩ go back to here" action on each of your messages, an "↩ undo" control in the session bar, and a dialog that previews what will happen before anything does.
    • TUI and codeoid attach: /undo takes back the last message; /undo files previews restoring the files; /undo files yes does it.
    • CLI: codeoid undo <session> [files [yes|force]].
  • Every backend, after a restart too. The cut happens in codeoid's own copy of the conversation. The backend is then reset and re-seeded with what's kept, the same way a backend switch works. Nothing depends on a backend's own session format.
  • Files are opt-in and careful:
    • What changes: files the agent changed or deleted get their old content back, and files created since are removed. Ignored files (build output, dependencies) and secret files are never touched.
    • Your own edits: if you edited a file by hand since the agent's last turn, going back reports it and refuses to overwrite it unless you confirm.
  • Honest about limits. The preview lists what going back can't undo: external tool calls (MCP), and shell commands whose effects live outside the working directory, such as git push, gh, curl, installs or deployments.
  • A running turn is stopped first, along with background agents. Messages you send after going back are not affected.
  • Permissions:
    • Going back needs send rights.
    • The preview also needs read access.
    • Restoring files needs approve rights, because it writes outside the approval gate.
    • Stopping a busy session needs interrupt rights.
  • Confirm does exactly what was previewed. If anything changed in between, it refuses and shows the fresh preview.
  • Records. What was taken back is hidden from the session view (live, after a restart, when paging, and in forks) but kept on disk. A visible "↩ Went back to before …" notice marks the spot, and the action is audit-logged.

How it was checked

Live, against a real daemon with the real Claude backend (scratch git repo):

Step Result
Turn 1: "My codename is PELICAN. Create hello.txt = one". Turn 2: "Correction: codename is now FALCON. Append two, create notes.md". Files: hello.txt = one/two, notes.md exists.
Dry run of going back to turn 2 with files Would restore hello.txt, remove notes.md, no conflicts. Nothing changed.
Go back to turn 2 with files hello.txt = one, notes.md gone, the turn-2 prompt returned.
"Without tools: what is my codename?" PELICAN. FALCON was forgotten.
Edit hello.txt by hand, then codeoid undo <s> files and files yes Preview flags hello.txt (edited by you since). files yes refuses; the hand edit survives.
codeoid undo <s> (conversation only) The last message is taken back and printed for resending.
Restart the daemon The turn list and both "went back" notices persist, and the taken-back rows stay hidden.

Repeated after both audit rounds. Two undos in a row, after taking everything back: "No, you haven't told me a codename". The agent forgets on a real backend.

Tests: typecheck, lint and build pass, plus 2,886 daemon tests and 585 web tests. Each guard added during the audits was mutation-checked: removing it makes a test fail.

  • New daemon tests: rewind.test.ts (30) covers:
    • the conversation cut, client views, restarts, forks and permissions;
    • a running turn, a pending approval, background reports, and a message sent during a rewind;
    • file restore: hand edits after the last turn and between removed turns, fail-closed when a snapshot is missing, ignored files, symlinked parents, a preview/confirm mismatch, and non-git folders;
    • the agent forgetting after two undos, and after a restart.
    • Also terminal-rewind.test.ts (5) and CLI replay tests.
  • New web tests: RewindModal.test.tsx (6) and two MessageRow tests.

Audits: two rounds, each with a correctness reviewer and a security reviewer. All findings were fixed here. The ones that shaped the design:

  • Restore: it's a single git operation that refuses rather than half-restores. It never deletes a file that was ignored when the snapshot was taken.
  • Hand edits: they're checked across every taken-back turn and fail closed.
  • Permissions: the scopes are listed above.
  • The agent really forgets: every backend drops its queued history on reset. Background reports from removed turns don't wake the agent, and a restart before the next message re-seeds the kept history.
🤖 Implementation context (for agents / maintainers)

Protocol (packages/protocol/src/turns.ts)

  • session.rewind { sessionId, turnId, restoreFiles?, dryRun?, force? } returns session.rewind.result { removedTurns, restoredPrompt, files?: { restore, remove, conflicts, late?, applied }, filesUnavailable?, irreversible, refused?, dryRun }. Scope: session:send + ownership. Refused for conductor/role sessions.

Daemon (src/daemon/session.ts)

  • rewind(): dry runs go straight to #planRewind. Real runs serialize on the send chain via #rewindInner.
  • #rewindInner takes these steps in order:
    1. Interrupt the running turn, any send still preparing, and background work.
    2. Plan.
    3. Refuse on conflicts unless force.
    4. restoreTree.
    5. #teardownProvider + accumulator.seed(kept).
    6. Trim the turn index, then resetToNewSession (new backing id) + #seedProviderFromHistory.
    7. Append a marker row {event: "session.rewound", turnId, removedTurnIds, fromMessageId}.
    8. Rebuild the scrollback via applyRewinds, rotate #resumeKey, #replay a snapshot to every client, and audit.
  • End-of-turn snapshots (<turnId>-end, #snapshotTurnEnd on idle/error). Snapshots are serialized through #checkpointChain so a start snapshot waits on the previous end.
  • irreversibleEffects() covers MCP tool calls and run_shell commands matching an outside-effect pattern.

Checkpoints (src/daemon/checkpoints.ts)

  • currentTree stages the work tree, without a ref.
  • diffTrees is diff-tree -r -z --name-status.
  • restoreTree:
    • Path checks first: every path must resolve inside the workdir's realpath through no symlinked parent.
    • Created since: removed.
    • Changed or deleted since: written back with git checkout <sha> --pathspec-from-file under GIT_LITERAL_PATHSPECS.

Transcript (src/daemon/transcript.ts)

  • applyRewinds runs inside loadTranscript. It cuts positionally from fromMessageId to the marker, and falls back to dropping rows stamped with removedTurnIds. That covers resume, fork priming and paging.

Clients

  • Web: components/RewindModal.tsx. The taken-back prompt goes back to the draft via a codeoid:prompt-draft-changed event that PromptBox listens for. MessageRow gets the "go back to here" action and SessionControls the UndoButton.
  • TUI: /undo in App.tsx, plus ws turns / rewind.
  • CLI: /undo in attach (prefills via rl.write) and codeoid undo. Shared grammar and wording in src/terminal/rewind.ts.

Known limits

  • Memory episodes already indexed for the taken-back turns stay in memory search.
  • Usage totals are not rolled back.
  • Git commits made by the agent are listed as irreversible, not reverted.

🤖 Generated with Claude Code

saucam and others added 4 commits October 11, 2026 01:30
…restoring the files

Closes #355. Builds on the turn ids and workspace snapshots of #354.

- session.rewind takes back a turn and every turn after it. Backend-
  agnostic: the cut happens in codeoid's canonical history and the backend
  is reset and re-seeded from what's kept (the backend-switch path), so
  every current and future provider forgets the removed turns, restart or
  not. A running turn, a send preparing one, and background agents are
  stopped first; rewinds serialize with sends.
- restoreFiles puts the working directory back to that turn's starting
  snapshot (restore changed/deleted files, remove created ones; ignored and
  secret files untouched; every path checked to stay inside the workdir).
  An end-of-turn snapshot tells hand edits from the agent's: files edited
  by hand since the agent's last turn are reported as conflicts and only
  overwritten with force. dryRun previews everything and changes nothing.
- The result lists what can't be undone (external MCP calls, shell commands
  with effects outside the workdir) and returns the taken-back prompt.
- A durable marker in the transcript hides the taken-back rows on replay,
  restart, paging and forks alike; every attached client gets a fresh
  snapshot view.
- Web: "go back to here" on your messages, an undo control, and a dialog
  that previews first; the prompt returns to the composer. TUI and
  `codeoid attach`: /undo [files [yes|force]]. CLI: codeoid undo.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d edits, the agent truly forgets

Audit round 1 on #355:

Files
- Restore is one git operation: a two-tree `read-tree -m -u` over a
  throwaway index. Git refuses (changing nothing) if any file changed since
  the plan, never writes or unlinks through a symlinked parent, never
  overwrites an untracked file; nested repos are no longer fatal.
- A file the snapshot didn't hold because it was ignored under that
  snapshot's own .gitignore is never deleted; a path now taken by an ignored
  file is left alone.
- Hand edits are checked in every gap between the removed turns (one's end
  snapshot → the next's start) and after the last; a missing snapshot fails
  closed (every change counts, `unverified`).
- The backend is torn down before files are restored; snapshot, preview
  and restore share one queue, so a preview can't make a snapshot skip.
- planId binds a confirm to its preview: anything changed → refused.

The agent forgets
- Every warm provider clears its queued history seed on reset — a second
  undo could replay the turns just taken back.
- Queued background reports and the wake timer are dropped, and nothing
  may wake the agent mid-rewind.
- A restart before the next message re-seeds the kept history
  (backingFresh in the session meta) instead of resuming an empty backend.

Safety and accuracy
- Restoring files needs session:approve; stopping a busy session needs
  session:interrupt. waiting_approval counts as busy; sends queued after the
  rewind are no longer cancelled by it.
- `codeoid attach` /undo pre-fills only a single sanitized line (readline
  submitted every embedded line); /undo files yes confirms the previewed
  plan; a refreshed view no longer reprints the whole session.
- Shell commands are recognized under every backend's tool name; secret-
  looking values are redacted; only codeoid's own markers are honoured;
  refusals and failures are audited with force and counts.
- Web: confirms the previewed plan, re-previews if it changed, puts the
  prompt above a half-typed draft.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d edits, survives restarts

Audit round 2 on #355:

- Interrupt rights are checked inside the serialized rewind (the session
  can get busy while the request queues), an unknown turn stops nothing,
  and an idle session is planned and refused BEFORE anything is torn down
  — a refusal really is "nothing changed".
- The rewind claims its stop point synchronously: messages sent after it
  are no longer cancelled by its interrupt.
- A path the restore would recreate never lands on an untracked file at a
  parent (a file where a deleted directory comes back); exact-name checks
  for case-insensitive filesystems; paths under a symlinked directory are
  left alone and reported as `skipped`.
- After a restart the real end-of-turn snapshot is kept: restored turns
  count as ended, and an end snapshot is never overwritten.
- A preview of a running session treats the live files as that turn's end,
  so confirming it no longer always fails the first time.
- A preview needs attach/watch; the TUI pre-fill is stripped of control
  sequences; redaction covers Basic auth, cookies, URL credentials,
  -u/-p arguments, secret-named variables and bare JWTs (bounded input).
- Restore reuses the shadow index's stat cache (no full re-hash); planning
  makes no per-file git calls; ignore files are read in one cat-file batch;
  end snapshots no longer halve how far back files can go.
- A background turn restores an empty prompt, not the harness text; the
  CLI tells a chunked first replay from a refresh.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…doing it

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@saucam
saucam changed the base branch from feat/turn-checkpoints-354 to main October 10, 2026 17:30
@saucam saucam closed this Oct 10, 2026
@saucam saucam reopened this Oct 10, 2026
@saucam
saucam merged commit fa076a8 into main Oct 10, 2026
4 checks passed
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.

No way to take back a message: add "go back a turn" with optional file restore and edit-and-resend

2 participants