Skip to content
Draft
38 changes: 38 additions & 0 deletions docs/development/native-appearance-welcome/BACKUPS_AND_RESET.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Backups and guarded reset

Implements the supplied BRIEF-BACKUPS.md in the assigned worktree. No dependencies, PTY changes, shader generation changes, or release version changes.

## Design
A private, bounded journal records checked before-images and expected after hashes before the existing host transaction can mutate files. All readers reject symlinks and oversized files. Restores capture current snapshots for stale checking and create another journal entry, making undo reversible. Shared studio shells render history and a four-step reset state machine; no write occurs while planning or rendering. Reset discovers ownership and delegates to existing removal planners, retaining user content and reporting refused areas.

The shared transaction is preferred over independent backup implementations because existing snapshot checking and rollback remain authoritative. Legacy writers retain their own validation and use that transaction for their final write. State metadata needed to undo ownership changes belongs in the same journal entry as external files.

## Implementation plan
- [x] Journal: test missing behavior, implement private atomic read-back-verified entries, caps and pruning, integrate transact, run focused tests, commit.
- [x] Writers: preserve preconditions, route external final writes through journaled transactions; daily settings checkpoint; focused regression tests, commit.
- [x] Restore and history: stale snapshots, current-to-before diff, edited-file second confirmation, undoable restore, manual snapshots; shared responsive studio panel; tests, commit.
- [x] Reset: discover owned areas, exact impact, backup location, exact typed reset confirmation, recheck all snapshots, per-area results; command/help wiring; tests, commit.
- [x] Verify affected tests using supplied runner, TypeScript and diff checks; save panel snapshots and final evidence report.

## Review focus
Reject symlink swaps; refuse writes if backups cannot be read back; preserve user newlines and subsequent unrelated edits; refuse stale plans; keep cancellation and confirmation visible at small sizes. Tests use private home directories. Automated results do not establish physical terminal validation.

## Implementation details and safety decisions

`src/backups/journal.ts` stores private before-images and verified after-images. The expected after hash is present in a prepared manifest; a successful transaction verifies bytes and permissions and marks it applied. After-images let ownership-only reset reverse exact NMSh edits across saves while preserving user edits made between those saves. A restore is a new protected entry. It also checkpoints config.json without changing it unless settings were part of the chosen restore.

Entries publish by directory rename only after staged content and manifest read back correctly. Interrupted staging is retained for inspection and counts toward storage limits. A short exclusive process lock serializes entry creation; a lock from a demonstrably dead process can be recovered. Automatic, applied entries may be pruned. Reset, restore, manual and unfinished checkpoints remain protected. A private pruning index prevents legacy resets from treating an incomplete retained history as proof of original ownership.

Host appearance and shader removal use their existing planners. Legacy cursor, keyboard and Starship reset requires verified applied history with after-images, exact target paths, and unambiguous owned changed lines. Without that evidence, the area is skipped. Changed user lines are never guessed at. Theme Bridge and status-line writes checkpoint their ownership records in the same transaction as their host files; restoring an artifact therefore restores its ability to be managed later.

The reset wizard merges disjoint removal plans at whole-line boundaries. Conflicting plans are skipped with a reason. Every captured dependency is rechecked before and after the global reset checkpoint. Each area then uses the checked transaction and reports done, skipped or failed; remaining areas are skipped if an application fails. The global checkpoint remains available even after partial failure.

History uses the shared Studio layout; exact diffs, overwrite confirmation and scrollable result documents use the shared review renderer. Only optional control/subtitle parameters were added to that renderer. At small sizes the scroll position and cancellation/confirmation controls remain visible. Reset starts with no selection; settings are a separate area. A wrong final word cancels. No scheduler or animation loop was added.

Settings saves keep their merge-preserving behavior and take at most one daily checkpoint. Checked snapshots and exclusive staging prevent a save from overwriting a change that arrived during the backup. Host writes and reset/restore require HOME-contained, non-symlink paths. Legacy fixtures that intentionally write config now declare HOME and XDG consistently. Explicit NMSh state paths retain the settings API's existing state-directory behavior.

## Verification and handoff

Focused tests cover journal read-back, corruption, bounds, permissions, symbolic links, live writer exclusion, backup failure, settings coexistence, stale restore, reversible restore, owned-edit preservation, shared-file reset planning, command routing, modal ownership, wrong-word cancellation, all wizard stages and responsive rendering under NO_COLOR. Existing adapter, host, cursor, tmux, prompt persistence, Ask, palette, setup and routing tests were also run. Logs and plain-text/ASCII panel snapshots are in the task evidence folder; the final report names authoritative runs. No full-suite run or physical terminal validation is claimed.

Manual terminal checks still needed: open /changes, review and restore a harmless config change, confirm the extra warning after a later user edit, then undo the restore. In /nmsh reset select one installed area, inspect every diff and backup path, cancel, reopen and complete with the exact word. Verify personal lines and newline style survive, result scrolling works at small sizes, and /reset only explains the guarded command. Test in the supported terminal hosts and with NO_COLOR. Automatic host reload is intentionally left to the lead's integration at `TerminalApp.afterHostFilesChanged(paths)`; the panels currently explain that a reload may be needed.
33 changes: 15 additions & 18 deletions src/agents/claudeStatusLine.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {mkdirSync, readFileSync, renameSync, rmSync, writeFileSync} from 'node:fs';
import {snapshot, type FileChange} from '../appearance/hostFiles.js';
import {journalDirectory} from '../backups/journal.js';
import {readFileSync} from 'node:fs';
import {homedir} from 'node:os';
import {dirname, isAbsolute, join} from 'node:path';
import {isAbsolute, join} from 'node:path';
import {applyPlan, inspectFile, planCreate, planJsonSet, planReplace, sha256, type FileEditPlan} from '../ask/fileEdit.js';
import {nmshConfigDirectory} from '../configuration/paths.js';
import {posixQuote} from '../shell/adapters/ShellAdapter.js';
Expand Down Expand Up @@ -57,13 +59,8 @@ function readOwnership(env: NodeJS.ProcessEnv): OwnershipRecord | undefined {
} catch { return undefined; }
}

function writeOwnership(env: NodeJS.ProcessEnv, record: OwnershipRecord | undefined): void {
const path = ownershipPath(env);
if (!record) { rmSync(path, {force: true}); return; }
mkdirSync(dirname(path), {recursive: true, mode: 0o700});
const temporary = `${path}.${process.pid}.tmp`;
writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\n`, {mode: 0o600});
renameSync(temporary, path);
function ownershipChange(env:NodeJS.ProcessEnv,record:OwnershipRecord|undefined):FileChange {
return {before:snapshot(ownershipPath(env),env.HOME || homedir(),true),after:record?`${JSON.stringify(record,null,2)}\n`:undefined};
}

const allowedRoots = (env: NodeJS.ProcessEnv) => [env.HOME && isAbsolute(env.HOME) ? env.HOME : homedir(), claudeConfigDirectory(env)];
Expand Down Expand Up @@ -116,12 +113,12 @@ export function planClaudeBridge(launcher: string, env: NodeJS.ProcessEnv = proc
}

export function applyClaudeBridge(plan: FileEditPlan, launcher: string, env: NodeJS.ProcessEnv = process.env, now = new Date()): {ok: true} | {ok: false; reason: string} {
const applied = applyPlan(plan);
if (!applied.ok) return applied;
const created = plan.operation === 'create';
writeOwnership(env, {settingsPath: plan.resolvedPath, command: bridgeCommand(launcher), inserted: plan.edits[0]!.text,
created, resultSha256: plan.resultSha256, appliedAt: now.toISOString()});
return {ok: true};
const created=plan.operation==='create';
try {
const record={settingsPath:plan.resolvedPath,command:bridgeCommand(launcher),inserted:plan.edits[0]!.text,created,resultSha256:plan.resultSha256,appliedAt:now.toISOString()};
return applyPlan(plan,{home:env.HOME || homedir(),source:'statusline',directory:journalDirectory(env.HOME || homedir(),env),label:plan.reason,companions:[ownershipChange(env,record)]});
}catch(error){return {ok:false,reason:error instanceof Error?error.message:String(error)};}

}

/** Removal deletes exactly the text NMSh inserted, only while the status line is still NMSh's. */
Expand All @@ -144,7 +141,7 @@ export function planClaudeBridgeRemoval(env: NodeJS.ProcessEnv = process.env): B
}

export function applyClaudeBridgeRemoval(plan: FileEditPlan, env: NodeJS.ProcessEnv = process.env): {ok: true} | {ok: false; reason: string} {
const applied = applyPlan(plan);
if (applied.ok) writeOwnership(env, undefined);
return applied;
try {return applyPlan(plan,{home:env.HOME || homedir(),source:'statusline',directory:journalDirectory(env.HOME || homedir(),env),label:plan.reason,companions:[ownershipChange(env,undefined)]});}
catch(error){return {ok:false,reason:error instanceof Error?error.message:String(error)};}

}
51 changes: 48 additions & 3 deletions src/app/TerminalApp.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import {Journal, journalDirectory} from '../backups/journal.js';
import {createChangesPanel,changesKey,renderChangesPanel,type ChangesPanel} from '../backups/ChangesPanel.js';
import {createResetPanel,resetKey,renderResetPanel,type ResetPanel} from '../backups/ResetPanel.js';
import {ownedSnapshotPaths} from '../backups/reset.js';
import {inlineWelcomeReason} from '../welcome/render.js';
import {WelcomeStudioController} from '../welcome/studio.js';
import {NativeWelcomePresentation} from '../welcome/startup.js';
Expand Down Expand Up @@ -1060,6 +1064,8 @@ export class TerminalApp {
private welcomeBlinkCount = 0;
private contextGeneration = 0;
private appearanceHub?: AppearanceHubState;
private changesPanel?: ChangesPanel;
private resetPanel?: ResetPanel;
/** /appearance › Kitty shaders (Kitty 0.49+ pipelines); opened from the hub, Esc returns there. */
private kittyShaderPanel?: KittyShaderPanelState;
/** /cursor › Host native is reading Kitty's catalog to choose the studio or the older setup; a second Enter waits for it. */
Expand Down Expand Up @@ -2532,6 +2538,12 @@ export class TerminalApp {
this.render();
return;
}
if (this.changesPanel || this.resetPanel) {
const action = this.changesPanel ? changesKey(this.changesPanel,key) : resetKey(this.resetPanel!,key);
if (action?.paths) this.afterHostFilesChanged(action.paths);
if (action?.close) { this.changesPanel=undefined;this.resetPanel=undefined;this.returnFromPanel(); }
this.render();return;
}
if (this.appearanceHub) {
const wasRouting = this.appearanceHub.view === 'routing';
const action = appearanceHubKey(this.appearanceHub, key, this.promptConfiguration);
Expand Down Expand Up @@ -3278,6 +3290,21 @@ export class TerminalApp {
}
}
else if (slash.kind === 'copySettings') { this.panelOrigin = undefined; this.rowPanel = createRowPanel('Copy', '/copy and /cp', ['copyMode', 'copyIncludeStatus', 'copyAutoExpand']); }
else if (slash.kind === 'resetGuidance') this.output.addFrontendInteraction(command.trim(), 'Use /nmsh reset to choose only NMSh-owned settings, review impact and backup, then type reset to confirm. /reset writes nothing. Browse or undo changes with /changes.', INFO);
else if (slash.kind === 'changes' || slash.kind === 'nmshReset') {
this.panelOrigin=undefined;
try {
const home=process.env.HOME || homedir();
const journal=new Journal({home,directory:journalDirectory(home)});
const context={env:process.env,platform:process.platform};
if (slash.kind==='changes') {
this.changesPanel=createChangesPanel(journal,[]);
try {this.changesPanel.ownershipPaths=ownedSnapshotPaths(context,journal);}
catch(error){this.changesPanel.message=`Ownership discovery is incomplete: ${error instanceof Error?error.message:String(error)}. Journal history remains available.`;}
}
else this.resetPanel=createResetPanel(context,journal);
} catch(error) {this.output.addFrontendInteraction(command.trim(), `Cannot open backup tools: ${error instanceof Error?error.message:String(error)}`, ERROR);}
}
else if (slash.kind === 'appearance') { this.panelOrigin = undefined; await this.startAppearance(); }
else if (slash.kind === 'motion') {
// The same Motion screen and state as /appearance → Motion.
Expand Down Expand Up @@ -3880,6 +3907,22 @@ export class TerminalApp {
this.render();
}

/** After reviewed restore/reset writes: reload the verified terminal NMSh runs in (when it is one NMSh can reload) and reload settings. */
private afterHostFilesChanged(paths: readonly string[]): void {
if (!paths.length) return;
const host = this.cursorFacts().host;
if (host !== 'other') this.output.addFrontendInteraction('/changes', this.reloadNativeHost(host).message, INFO);
// Load disk state without the settings setter: restoring files must not regenerate the files just restored.
this.configuration=loadPromptConfiguration();
this.effectivePromptProvider=this.configuration.provider;
this.externalPrompt=undefined;
this.externalPromptError=undefined;
this.themeStopsKey='';
this.cursorHost=undefined;
this.renderer.setCursorStyle(cursorStyleSequence(this.configuration.cursor.shape,this.configuration.cursor.blink));
setIconStyle(this.configuration.glyphStyle);
}

/** /appearance: the visual hub. NMSh rows always work; host window rows edit opacity/blur only where the host supports it. */
private async startAppearance(): Promise<void> {
let host: AppearanceState | undefined;
Expand Down Expand Up @@ -6573,7 +6616,7 @@ export class TerminalApp {

private hasModalInput(ignorePalette = false): boolean {
return Boolean(this.welcomeStudio || this.stopsEditor || this.chromeEditor || this.screensaverPanel || this.themeStudio || this.themeBridgePanel || this.rowPanel || this.copyPicker || this.summaryReview || this.reportReview || this.comparePanel || this.arcade || this.browserPanel || this.docSession || this.imageView || this.viewer || this.textEditor || this.background.panel || this.batchReview || this.pinsPanel || this.stripStudio || this.configureList || this.tmuxPanel || this.dotfiles || this.setupState || this.installPrompt || this.presetPanel || this.toolsPanel || this.toolConfigurationLoading || this.toolConfiguration || this.promptPanelState || this.transcriptPanelState || this.providerPanelState || (!ignorePalette && this.paletteState) || this.syntaxPanelState || this.layoutPanelState || this.settingsPanelState
|| this.resumeBrowser || this.appearanceHub || this.kittyShaderPanel || this.glslStudio.panel || this.keyboardState || this.startupPanel || this.aboutPanel || this.shellPanel || this.openPanel || this.askState || this.agentView || this.agentPanel || this.launcher || this.modsPanel || this.worktreePanel || this.githubPanel || this.providersOverview || this.understandingPanel || this.cursorPanel || this.doctorPanel || this.watchPanel || this.queuePanel || this.queueHistoryPanel || this.pasteReview || this.misePanel || this.keepAwakePanel);
|| this.resumeBrowser || this.changesPanel || this.resetPanel || this.appearanceHub || this.kittyShaderPanel || this.glslStudio.panel || this.keyboardState || this.startupPanel || this.aboutPanel || this.shellPanel || this.openPanel || this.askState || this.agentView || this.agentPanel || this.launcher || this.modsPanel || this.worktreePanel || this.githubPanel || this.providersOverview || this.understandingPanel || this.cursorPanel || this.doctorPanel || this.watchPanel || this.queuePanel || this.queueHistoryPanel || this.pasteReview || this.misePanel || this.keepAwakePanel);
}

/** Complex panels declare the smallest size that shows their essential controls. */
Expand Down Expand Up @@ -6779,6 +6822,8 @@ export class TerminalApp {
else if (!busy && this.studioPreviewClock) { this.studioPreviewClock(); this.studioPreviewClock = undefined; }
return framePanel(rows, columns);
}
if (this.changesPanel) return renderChangesPanel(this.changesPanel,columns,this.dimensions().rows-4);
if (this.resetPanel) return renderResetPanel(this.resetPanel,columns,this.dimensions().rows-4);
if (this.appearanceHub) {
const theme = themeLabel(this.promptConfiguration.nmsh.palette);
const now = Date.now();
Expand Down Expand Up @@ -10675,7 +10720,7 @@ export class TerminalApp {
let written: ManagedWrite;
try { written = writeManagedFiles(native.host, resolveCursorSettings(state.draft, contextFor(this.promptConfiguration))); } catch { state.message = 'Could not write NMSh\'s managed cursor files; nothing else was changed.'; return; }
if (setup.plan.kind === 'plan') {
const applied = applyPlan(setup.plan.plan);
const applied = applyPlan(setup.plan.plan, {home:process.env.HOME || homedir(),source:'cursor',label:'Set up native cursor include',directory:journalDirectory(process.env.HOME || homedir())});
if (!applied.ok) { state.message = applied.reason; return; }
}
this.cursorHost = hostCursorFacts(process.env, value => nativeCursorIntegrated(value), () => writtenKittyTrail({env: process.env, platform: process.platform}), () => this.glslStudio.trailFacts());
Expand Down Expand Up @@ -11306,7 +11351,7 @@ export class TerminalApp {
return;
}
if (action.kind === 'applyEdit') {
const applied = applyPlan(action.plan);
const applied = applyPlan(action.plan, {home:process.env.HOME || homedir(),source:'ask',label:action.plan.reason,directory:journalDirectory(process.env.HOME || homedir())});
if (!applied.ok) { finish(applied.reason); return; }
// Verify by reading back: the file must now be exactly what the preview showed.
let verified = false;
Expand Down
Loading
Loading