Plan: Autosave, with version history (from intent.md 2026-10-23)#
Files that change#
PR 1, the program (feat/autosave-program)
crates/pixellab/src/author.rs(new):Author(owner, agent, cli, outside, restore, several, opened) as JSON and a label suffix ("(Claude)").crates/pixellab/src/history.rs(new): the history folder of D3 (document.json,versions.jsonl,blobs/,lock),record,mark, version 0, outside changes, thinning, a file's text at a version.crates/pixellab/src/owner.rs(new): the owner lock and socket of D9 (key,try_lock, contents, stale socket, long paths, peer uid), and the listener thread that feeds the stdio server's channel.crates/pixellab/src/client.rs(new): D10's lookup,client.hello, forwarding a call,owner-gone,version,doc.adopt.crates/pixellab/src/commands/history.rs(new):history.list,history.show,history.render,history.restore,history.undo,history.mark.crates/pixellab/src/session.rs:Step.authorand the request's author; autosave (autosave_ms,dirty_since,blocked),flush,saveStateand labels in the status; a save records history and merges a conflict; client undo rules;adopt; proposals andMode::Proposeremoved;synckeeps only the clean-doc reload.crates/pixellab/src/commands/doc.rs:doc.flush,doc.adopt;doc.savemarks a version;doc.undo,doc.redo,doc.revertanddoc.closefrom a client (D7).crates/pixellab/src/commands/proposal.rs(deleted;activity.listmoves tocommands/activity.rs, new),crates/pixellab/src/open_docs.rs(deleted in step 7, when the owner lock replaces it),crates/pixellab/src/commands/mod.rs,crates/pixellab/src/registry.rs,crates/pixellab/src/lib.rs.crates/pixellab/src/state.rs: proposal functions removed; first-run retirement ofproposals/withretired-proposals/notice.json(R21); the doc comment's layout.crates/pixellab/src/server_stdio.rs: the owner claim,Event::Remote, autosave on the tick, shutdown flush,ownedonsession.info,history.changed;proposals.changedand open-docs removed.crates/pixellab/src/server_mcp.rs: forwarding throughclient.rs; a local session that saves after each mutating call;--directignored;INSTRUCTIONS,LEFT_OUT, tool descriptions.crates/pixellab/src/cli.rs,crates/pixellab/src/serve.rs: forwarding throughclient.rs(save: true,--dry-run),document.migraterefused while owned.crates/pixellab/src/watch.rs: the state watcher drops proposals.crates/pixellab/src/help.rs: the state dir's description; the MCP section.crates/pixellab/src/activity_summary.rs,crates/pixellab/src/chat_context.rs: proposal wording.- Tests:
crates/pixellab/tests/autosave.rs,history.rs,owner.rs(new);servers.rs,session.rs,state.rs,chat.rs,watch.rs,retire.rs,edit_commands.rs,depth_edit.rs,ambient_commands.rs,scene_canvas.rs,layers.rs,params.rs,help.rs,commands.rs,groups.rs,motion_ops.rs,art_budget.rs(proposal cases replaced by applied edits);sync.rs(trimmed, not deleted: its proposal settling cases go, and its reload, edits-since-disk and merge cases stay);tests/common/server.rs(new: the servers harness, shared). docs/cli.md(generated).
PR 2, the app, scripts and docs (feat/autosave-app)
app/Sources/PixelLab/Server/Protocol.swift:DocStatus.saveState,problems,author,undoLabel,redoLabel;Version,HistoryList,HistoryShow; proposal types removed.app/Sources/PixelLab/Server/ServerConnection.swift:PIXELLAB_APP_PID; theownedanswer.app/Sources/PixelLab/Model/AppModel.swift:flush(replacingsave's alerts), the R1 triggers,confirmUnsavedfor problems only,saveVersion(⌘S),restoreLastOpened, theownedhand-off (activate the other copy and quit).app/Sources/PixelLab/Model/VersionsModel.swift(new): the sheet's list, selection, ghost, restore and undo.app/Sources/PixelLab/Model/ProposalGhost.swift→VersionGhost.swift(renamed): the ghost fed byhistory.render.app/Sources/PixelLab/Model/AgentModel.swift: proposals, ghost and badges removed; the retirement notice.app/Sources/PixelLab/Chat/ChatModel.swift,ChatStore.swift,ChatRows.swift,StepText.swift,ClaudeTurn.swift,ChatPrompt.swift: change cards,history.markat a turn's end, flush before send.app/Sources/PixelLab/Views/VersionsSheet.swift(new): D5, as a panel over the stage's trailing edge (see Departures).app/Sources/PixelLab/Views/ChatBubbles.swift:ChangeCardreplacing the proposal card.app/Sources/PixelLab/Views/AgentWindow.swift,AgentTab.swift,RightColumn.swift,StageOverlay.swift,StageView.swift,StaleBanner.swift,InspectorView.swift,MainWindow.swift: proposals out, Save out, the problems row, the subtitle and edited dot, Reload and Keep Mine.app/Sources/PixelLab/PixelLabApp.swift,app/Sources/PixelLab/Support/WindowGuard.swift: File › Save (a version), Revert To › Last Opened / Browse All Versions…, flush on resign, quit and close.app/Sources/PixelLab/Support/ScriptRunner.swift: D12's lines.app/Tests/PixelLabTests/AutosaveTests.swift,VersionsTests.swift(new);AgentTests.swift,ChatTests.swift,StaleTests.swift,CanvasTests.swift(proposal cases replaced);app/Tests/PixelLabTests/Fixtures/stream-fake-move.jsonl.scripts/acceptance/agent.txt,chat.txt,chat-harbor.txt,chat-setup.txt,chat-relaunch-1.txt,chat-relaunch-2.txt,stale.txt,mcp-agent.mjs,stale-agent.mjs,versions.txt(new),owner.txt(new),scripts/acceptance.sh.scripts/fake-claude/fake-claude.mjs,turns.json,README.md.- Docs:
README.md,app/README.md,docs/architecture.md,docs/testing.md,CLAUDE.md(its proposal rule and script-hook lines), notes in../2026-10-mac-app/plan.mdand../2026-10-agent-chat/spec.md,docs/screenshots/versions-*.png(new).
Order of work#
intent/autosave-history (PR #49: intent, spec) ─┐
main ─────────────────────────────────────────── feat/autosave ── (plan) ─────────── PR 4 (owner merges) ──▶ main
├─ PR 2 the program ◀─ feat/autosave-program (steps 2–9)
└─ PR 3 app + scripts ◀─ feat/autosave-app (steps 10–17, cut after PR 2 merges)
feat/autosave is cut from intent/autosave-history, which holds the approved intent and spec (PR #49, which the owner merges into main). When #49 merges, main is merged into feat/autosave, which is then a no-op apart from the merge commit.
- This plan, committed as the first commit on
feat/autosave.
PR 2 (the program): steps 2–9
- Authors:
author.rs;Session.authorset per request;Step.author;undoLabelandredoLabelin the status. TestsR20: …for the labels. - History:
history.rs;Session::saverecords each write; version 0 on first open; coalescing within 60 s per author;mark; outside changes recorded where the session reloads or merges a changed file; thinning on open. TestsR8–R11. - History commands:
commands/history.rsand the registry;history.renderreusesscene.renderandobject.renderover the file text at that version;restoreandundoare one undo step per doc, saved, and recorded asrestore. TestsR13,R15. - Autosave:
autosave_ms,dirty_since,blocked;Session::autosave(now)andflush;saveState; a conflict merges, a clash blocks and sendsdoc.stale;doc.flush;doc.savemarks the version. TestsR1,R2,R6,R7. - Proposals out:
Mode(both variants),propose,proposal.*,Proposed,Entry.proposal, the state's proposal functions,proposals.changed(the state watcher now reportshistory.changed);State::newretiresproposals/with a notice;serve --mcp --directis ignored with a note; MCP's local session saves after each mutating call;INSTRUCTIONS,LEFT_OUTand descriptions rewritten. Every test that proposed now expects the edit on disk. TestsR16,R21,R23. - Owning a document:
owner.rs, replacingopen_docs.rs(and its use indocument.migrate); the stdio server takes the lock, binds the socket, and runsEvent::Remoterequests in its loop;session.infoanswersownedwhen another server holds it; shutdown flushes and marks. TestsR25,R26,R27, including a killed owner freeing the document. - Clients:
client.rs;serve --mcpand the CLI forward to an owner; client undo rules;owner-gone;version;doc.adopt;document.migraterefused while owned;--dry-runthrough a flush. TestsR22,R23,R24,R28: an MCP client and the CLI edit through a runningserve --stdioand the edit shows in its session and on disk. npm run docs:cli;pixellab help's state dir and MCP text; PR 2's checks; merge intofeat/autosave.
PR 3 (the app, scripts and docs): steps 10–17
- Protocol and model:
Protocol.swift,ServerConnection.swift(PIXELLAB_APP_PID,owned),AppModel(flush, triggers,confirmUnsaved,saveVersion,restoreLastOpened, the hand-off),AgentModelwithout proposals. - Views: Save out of
RightColumn, the problems row, the subtitle and edited dot, the menus,StaleBanner, the Agent window and tab without proposals, the retirement notice. - The Versions sheet:
VersionsModel,VersionsSheet,VersionGhost, its keys and VoiceOver labels. - The chat:
ChangeCard,history.markat the end of a turn that edited, flush before send,StepText. - The script hook: D12's lines;
AutosaveTests,VersionsTests; the proposal tests replaced. - Acceptance: the rewritten scripts,
versions.txt,owner.txt, and the fake Claude without proposals;scripts/acceptance.shruns them. - Docs: README,
app/README.md,docs/architecture.md,docs/testing.md,CLAUDE.md, the two records' notes;npm run docs:site. - Snapshots of the changed views in dark and light (the window with its subtitle, the problems row, the Versions sheet with a ghost, a change card, the Agent window), looked at, and added under
docs/screenshots/; PR 3's checks; merge intofeat/autosave.
PR 4: feat/autosave → main, with the verification output. The owner merges it.
Workflow and reviews#
| Step | Who | Where | Then |
|---|---|---|---|
| 1 | main session | feat/autosave | push |
| 2–9 | main session | feat/autosave-program, cut from feat/autosave | PR 2 into feat/autosave; the main session merges it once cargo fmt --check, cargo test and npm test pass |
| 10–17 | main session | feat/autosave-app, cut from feat/autosave after PR 2 merges | PR 3 into feat/autosave; merged once swift build, scripts/test-app.sh, scripts/build-app.sh and scripts/acceptance.sh pass |
| PR 4 | main session opens it | feat/autosave → main | the owner merges it |
- Runner. The main session runs every step, in order, in its own worktree (other sessions share the main checkout). No subagents: the owner didn't ask for them, and the two slices depend on each other.
- Why two slices. The program changes the state dir's format (history, owners, proposals retired) and the protocol the app speaks. It lands and passes its checks before the app is changed to it. The app slice also rewrites the acceptance pass, which needs both halves.
- Merges. The main session merges PR 2 and PR 3 into
feat/autosavewithgh pr merge --merge --delete-branch(the owner prefers merge commits) once their checks pass. Whenmainmoves, the main session merges it intofeat/autosavebefore the next slice starts. Conflicts are the main session's to resolve. - Owner reviews. None before PR 4. The owner set the goal of finishing to a completed build, and the spec settled the look and the choices. PR 4 carries the snapshots and the verification output, and the owner reviews them there. Installing and trying the build (
scripts/install.sh) is the owner's. - Independent review. A
verify-changerun on PR 4, in a fresh session, because the change rewrites the MCP server's surface and removes the approval path. The main session offers it as a session the owner can start, and fixes any blocking finding onfeat/autosavebefore the owner merges.
Risks#
- The riskiest step is 7–8, ownership and forwarding. Two processes, a lock and a socket, with ownership changing hands mid-session. Handled by: the stdio server's loop, which already runs requests one at a time; tests that start a real
serve --stdioand drive it with real MCP and CLI processes (owner.rs); a test that kills the owner with SIGKILL and checks the next CLI command writes directly. A request whose answer never arrives fails withowner-goneand is never retried. - Linux CI. The release workflow tests on Linux.
File::try_lockand Unix sockets work there.getpeereiddoesn't exist in glibc, so the peer check usesSO_PEERCREDthroughgetsockopton Linux, both declaredextern "C". If either fails to link, the 0700 folder alone guards the socket, and the plan is updated. - Socket paths over 104 bytes in tests' temp state dirs: D9's
$TMPDIRfallback, with a test that uses a deep state dir. - Undo changes meaning slightly. The owner's ⌘Z can undo an agent's step (concern 7, decided).
session.rstests pin the labels, and the client-undo refusal. - Removing proposals touches many tests (about 250 references across 18 Rust suites, 6 app files' tests and 9 acceptance files). Each proposal case becomes "the edit is on disk, with a version by that author", so the tests still cover the agent path.
- The real site checkout. No test or script touches it. Tests use
make_document()andmake_site(), the script hook refuses the real checkout, and every server test setsPIXELLAB_STATEto a temp dir, so history and owner files never land in the owner's state dir. - Autosave timing in the script hook. Waiting a second per edit would slow scripts down and make them flaky.
wait savedwaits onsaveState, and the hook's own runs pass a shortPIXELLAB_AUTOSAVE_MS(100). - Retiring proposals sets unapproved agent work aside (concern 4, decided): moved, never deleted, with a notice.
- Other options considered. Autosave in the app, not the session: rejected, because agents' and the CLI's edits wouldn't autosave the same way. macOS Versions (
NSFileVersion): rejected in the spec, because writes come from the program. Each process writing directly with merges (no owner): rejected, because autosaving every second would make clashes constant.
Departures#
What was built differently from the steps above, and why:
- Versions is a panel, not a sheet (D5). On the Mac a sheet comes down over the window and hides the stage, where the selected version's ghost is drawn. The panel sits over the stage's trailing edge instead, with the same list, detail, buttons and keys (Esc closes it).
VersionsSheetkeeps the spec's name. - Steps 10–14 landed as one commit. The protocol, model, views, the panel, the chat and the script hook change the same types (
DocStatus, the ghost, the chat's cards), so none of them builds without the others. - The script hook's
cliruns the program with a state dir of its own, so it stays what scripts use it for: an edit from outside Pixel Lab, which doesn't find the app's lock.cli-ownedis the CLI as a terminal runs it, through the app. sync.rsis trimmed, not deleted (step 6), andopen_docs.rsgoes in step 7 with the owner lock that replaces it.- History starts at the first write (step 9): version 0 is written with the first recorded write, as the document was just before it, so commands that only read leave the state dir alone.
- The script hook's
keysends a ⌘ key as a key equivalent (the window's views, then the main menu) before falling back toNSApp.sendEvent. Under the hook the app isn't frontmost, andsendEventalone dropped ⌘↓, socanvas.txt's drop failed onmain's build too. This isn't caused by autosave, but the acceptance pass has to exit 0.
Proof#
- Program:
cargo fmt --check;cargo testwith no failures, including the newautosave,historyandownersuites;npm testwith# fail 0;npm run docs:clileavesgit statusclean apart from the change's own edits. - App:
swift build --package-path appwith no errors;scripts/test-app.shwith every test passing. - End to end:
scripts/build-app.sh, thenscripts/acceptance.shexits 0. It includesversions.txt(edit,wait saved,versions open,version restore,version undo,expect versions …) andowner.txt(an MCP client andcli-ownededit through the app,expect scenepixelshows them on the stage,secondappis refused, quitting hands over, and a later CLI edit writes the file). - Docs:
npm run docs:sitebuilds with no broken links. - UI: snapshots in dark and light of the window and subtitle, the problems row, the Versions sheet with its ghost, a change card and the Agent window, looked at before reporting.