From 20d67bce23f76647a6254d9f64459ac7d09fdc46 Mon Sep 17 00:00:00 2001 From: Mike Lyons Date: Fri, 21 Aug 2026 19:24:35 -0400 Subject: [PATCH 1/3] Stop restored shell tabs from re-running their command A shell tab created by an agent persists the command it was launched with, and XTerminal treated that field as an instruction: any mount without a live PTY spawned `zsh -ilc `. Reopening Ness (or touching a tab whose command had already exited) therefore re-ran it unprompted. The command is only ever meant to run once, in createShell's eager spawn, so the renderer now always opens a plain interactive shell and the persisted command stays as a record of origin. Co-Authored-By: Claude Opus 4.7 --- src/main/persistence-migrations.ts | 3 ++- src/renderer/components/WorkspaceView.tsx | 1 - src/renderer/components/XTerminal.tsx | 17 +++++++---------- src/shared/state/terminals.ts | 6 ++++-- 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/main/persistence-migrations.ts b/src/main/persistence-migrations.ts index 6525c8dad..e86ff97b9 100644 --- a/src/main/persistence-migrations.ts +++ b/src/main/persistence-migrations.ts @@ -24,7 +24,8 @@ export interface PersistedTab { sessionId?: string /** For browser tabs: last URL so we can restore the tab on reload. */ url?: string - /** For shell tabs: command passed via `zsh -ilc ` (agent-spawned). */ + /** For shell tabs: the command the tab was created with (agent-spawned). + * Kept for display only — restored tabs do not re-run it. */ command?: string /** For shell tabs: cwd (absolute or relative to worktree root). */ cwd?: string diff --git a/src/renderer/components/WorkspaceView.tsx b/src/renderer/components/WorkspaceView.tsx index d87098c2c..187c7f3be 100644 --- a/src/renderer/components/WorkspaceView.tsx +++ b/src/renderer/components/WorkspaceView.tsx @@ -634,7 +634,6 @@ export function WorkspaceView({ initialPrompt={tab.initialPrompt} teleportSessionId={tab.teleportSessionId} modelOverride={tab.type === 'agent' ? tab.model : undefined} - shellCommand={tab.type === 'shell' ? tab.command : undefined} shellCwd={tab.type === 'shell' ? tab.cwd : undefined} onRestartAgent={ tab.type === 'agent' diff --git a/src/renderer/components/XTerminal.tsx b/src/renderer/components/XTerminal.tsx index 45950ccbb..624084ce3 100644 --- a/src/renderer/components/XTerminal.tsx +++ b/src/renderer/components/XTerminal.tsx @@ -267,9 +267,6 @@ interface XTerminalProps { initialPrompt?: string teleportSessionId?: string modelOverride?: string - /** Shell tabs only: when set, spawn ` -ilc ` instead - * of an interactive login shell. Used for agent-spawned shells. */ - shellCommand?: string /** Shell tabs only: directory to spawn in. Relative paths resolve against * `cwd` (the worktree root); absolute paths are used as-is. */ shellCwd?: string @@ -292,7 +289,7 @@ interface XTerminalProps { onSwitchToChat?: () => void } -export function XTerminal({ terminalId, cwd, type, agentKind, visible, sessionName, sessionId, initialPrompt, teleportSessionId, modelOverride, shellCommand, shellCwd, backgroundVar, preamble, hideRestoreNotice, onRestartAgent, onSwitchToChat }: XTerminalProps): JSX.Element { +export function XTerminal({ terminalId, cwd, type, agentKind, visible, sessionName, sessionId, initialPrompt, teleportSessionId, modelOverride, shellCwd, backgroundVar, preamble, hideRestoreNotice, onRestartAgent, onSwitchToChat }: XTerminalProps): JSX.Element { // Lazy font-cache init — fires once on first XTerminal mount. See // initFontCache() comment for why this is lazy rather than at module // top. @@ -634,12 +631,12 @@ export function XTerminal({ terminalId, cwd, type, agentKind, visible, sessionNa const shell = '' const agentArg = type === 'agent' ? await buildAgentArg() : '' if (disposed) return - const args = - type === 'agent' - ? ['-ilc', agentArg] - : shellCommand - ? ['-ilc', shellCommand] - : ['-il'] + // Shell tabs always come up as a plain interactive shell, even when the + // tab carries a `command`. Executing it belongs to the create_shell + // path in main, which spawns eagerly at creation time; doing it here too + // would re-run the command every time the PTY is gone but the tab isn't + // — i.e. on every app restart, and after the command's shell exits. + const args = type === 'agent' ? ['-ilc', agentArg] : ['-il'] const spawnCwd = shellCwd ? shellCwd.startsWith('/') ? shellCwd diff --git a/src/shared/state/terminals.ts b/src/shared/state/terminals.ts index b308ee6ff..84b3d5c74 100644 --- a/src/shared/state/terminals.ts +++ b/src/shared/state/terminals.ts @@ -58,8 +58,10 @@ export interface TerminalTab { teleportSessionId?: string /** For browser tabs: the URL currently loaded (restored on reload). */ url?: string - /** For shell tabs: command to run via `zsh -ilc ` instead of - * spawning an interactive login shell. Set by agents via the shell MCP. */ + /** For shell tabs: the command the tab was created with (agents set this via + * the shell MCP). A record of origin, not an instruction — it runs once, in + * `createShell`'s eager spawn. Remounting the tab (app restart, or after the + * command's shell exited) gives a plain interactive shell instead. */ command?: string /** For shell tabs: directory to run in. Relative paths resolve against the * worktree root; absolute paths are used as-is. */ From f613172e4add2507efc0682fac79759456bfd03f Mon Sep 17 00:00:00 2001 From: Mike Lyons Date: Fri, 21 Aug 2026 20:15:38 -0400 Subject: [PATCH 2/3] Replay persisted terminal scrollback on a cold app start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scrollback was already being written to userData/terminal-history/, but getHistory() only read the in-memory map, and that map is seeded from disk inside create(). On a cold start the renderer asks for history before it spawns, so it got '' and skipped the replay — create() then loaded the file into a buffer nobody ever displayed, which is why a renderer reload showed scrollback but relaunching the app did not. Both entry points now go through ensureHistoryBuffer, so the file is read on first touch (at most once per id) whichever call arrives first. Co-Authored-By: Claude Opus 4.7 --- src/main/pty-manager.test.ts | 41 +++++++++++++++++++++++++++ src/main/pty-manager.ts | 39 ++++++++++++++++--------- src/renderer/components/XTerminal.tsx | 10 +++---- 3 files changed, 71 insertions(+), 19 deletions(-) diff --git a/src/main/pty-manager.test.ts b/src/main/pty-manager.test.ts index da102dbca..8a6dce318 100644 --- a/src/main/pty-manager.test.ts +++ b/src/main/pty-manager.test.ts @@ -36,6 +36,7 @@ vi.mock('./persistence', () => ({ import { PtyManager } from './pty-manager' import * as pty from 'node-pty' +import { loadTerminalHistory } from './persistence' describe('PtyManager.create — eager spawn contract for createShell (#203)', () => { beforeEach(() => { @@ -67,3 +68,43 @@ describe('PtyManager.create — eager spawn contract for createShell (#203)', () expect(pty.spawn).not.toHaveBeenCalled() }) }) + +// On a cold app start the renderer asks for scrollback BEFORE it spawns, so +// getHistory has to reach the persisted file itself — reading only the +// in-memory map left restored tabs blank until something else seeded it. +describe('PtyManager.getHistory — persisted scrollback across an app restart', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns the persisted scrollback when nothing is in memory yet', () => { + vi.mocked(loadTerminalHistory).mockReturnValueOnce('previous run output') + const mgr = new PtyManager() + expect(mgr.getHistory('shell-restored-1')).toBe('previous run output') + }) + + it('returns empty string when no history was persisted', () => { + const mgr = new PtyManager() + expect(mgr.getHistory('shell-restored-2')).toBe('') + }) + + it('reads the file once per id — the spawn that follows reuses the buffer', () => { + vi.mocked(loadTerminalHistory).mockReturnValueOnce('previous run output') + const mgr = new PtyManager() + const id = 'shell-restored-3' + mgr.getHistory(id) + mgr.create(id, tmpdir(), '', ['-il'], undefined, true) + expect(loadTerminalHistory).toHaveBeenCalledTimes(1) + expect(mgr.getHistory(id)).toBe('previous run output') + }) + + it('drops both the buffer and the file on forgetHistory so a closed tab does not resurrect', () => { + vi.mocked(loadTerminalHistory).mockReturnValue('previous run output') + const mgr = new PtyManager() + const id = 'shell-restored-4' + expect(mgr.getHistory(id)).toBe('previous run output') + vi.mocked(loadTerminalHistory).mockReturnValue(null) + mgr.forgetHistory(id) + expect(mgr.getHistory(id)).toBe('') + }) +}) diff --git a/src/main/pty-manager.ts b/src/main/pty-manager.ts index 2bd9e4d6c..b316f11e2 100644 --- a/src/main/pty-manager.ts +++ b/src/main/pty-manager.ts @@ -85,8 +85,8 @@ export class PtyManager { private store: Store | null = null private sendSignal: ((channel: string, ...args: unknown[]) => void) | null = null // Per-terminal raw-byte scrollback owned by main. Populated from disk on - // create() (if a history file exists), appended to from the PTY onData - // stream, and returned on getHistory(id). Persistence to + // first touch by either getHistory(id) or create() (whichever comes first), + // appended to from the PTY onData stream. Persistence to // userData/terminal-history/ happens on a throttled cadence and on // before-quit via flushAllHistory(). private history = new Map() @@ -209,20 +209,15 @@ export class PtyManager { } // Seed the history buffer from disk if a file exists. Renderer calls - // getHistory(id) right after createTerminal and writes the bytes into a - // fresh xterm instance before wiring up live data. - let buf = this.history.get(id) - if (!buf) { - buf = new HistoryBuffer() - const existing = loadTerminalHistory(id) - if (existing) buf.seed(existing) - this.history.set(id, buf) - } + // getHistory(id) right before createTerminal and writes the bytes into a + // fresh xterm instance before wiring up live data, so this usually finds + // the buffer already loaded and skips the read. + const buf = this.ensureHistoryBuffer(id) ptyProcess.onData((data: string) => { // Tee into the history ring buffer before forwarding, so a reload // right after output arrives still sees it. - buf!.append(data) + buf.append(data) this.historyDirty.add(id) this.ensureHistoryFlushTimer() this.perfMonitor?.recordTerminalBytes(id, data.length) @@ -258,9 +253,25 @@ export class PtyManager { } } - /** Raw PTY scrollback for `id`, or empty string if none. */ + /** Raw PTY scrollback for `id`, or empty string if none. Falls back to the + * persisted file when nothing is in memory yet: on a cold app start the + * renderer asks for history BEFORE it spawns, so reading only the in-memory + * map returned '' and the saved scrollback stayed invisible until a renderer + * reload happened to hit the buffer create() had since seeded. */ getHistory(id: string): string { - return this.history.get(id)?.toString() || '' + return this.ensureHistoryBuffer(id).toString() + } + + /** The buffer for `id`, seeded from disk on first touch. Both getHistory and + * create go through here so the file is read at most once per id. */ + private ensureHistoryBuffer(id: string): HistoryBuffer { + const existing = this.history.get(id) + if (existing) return existing + const buf = new HistoryBuffer() + const persisted = loadTerminalHistory(id) + if (persisted) buf.seed(persisted) + this.history.set(id, buf) + return buf } /** Drop the in-memory buffer + delete the persisted file. Called on tab diff --git a/src/renderer/components/XTerminal.tsx b/src/renderer/components/XTerminal.tsx index 624084ce3..c5bbd18b4 100644 --- a/src/renderer/components/XTerminal.tsx +++ b/src/renderer/components/XTerminal.tsx @@ -699,11 +699,11 @@ export function XTerminal({ terminalId, cwd, type, agentKind, visible, sessionNa spawnPty() return } - // A non-empty history means main already has a live PTY for this id - // — the agent isn't "starting," we're attaching to a running one. - // Clear the loading overlay so the restored scrollback is visible - // without waiting for new bytes (which may never come if the agent - // is idle at its prompt). + // A non-empty history means this tab has run before — either main + // still holds a live PTY for it (we're attaching to a running one) or + // the scrollback came off disk from a previous app run. Clear the + // loading overlay so it's visible without waiting for new bytes + // (which may never come if the agent is idle at its prompt). setLoading(false) // Replay raw scrollback. Wait for xterm to finish parsing before // attaching onData, otherwise any response sequences xterm generates From 8857d72cb44be770f50c661aef5536e160477ace Mon Sep 17 00:00:00 2001 From: Mike Lyons Date: Fri, 21 Aug 2026 21:15:32 -0400 Subject: [PATCH 3/3] Leave a spent command shell as a transcript instead of a live prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shells created via create_shell run their command exactly once, at creation. Remounting such a tab after a restart handed back an interactive prompt, which is not how these shells ever behave — once the command's shell exits mid-session the tab is just a transcript. Drop the renderer's pty:create for a command shell that has no live PTY. Co-Authored-By: Claude Opus 4.7 --- src/main/index.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/main/index.ts b/src/main/index.ts index 69b367dc4..536c88074 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -56,7 +56,7 @@ import { FileContentWatcher } from './file-content-watcher' import { SnoozeTimer } from './snooze-timer' import { getWeeklyStats } from './weekly-stats' import type { TerminalTab, PaneNode, PaneLeaf } from '../shared/state/terminals' -import { getLeaves, mapLeaves } from '../shared/state/terminals' +import { findTabById, getLeaves, mapLeaves } from '../shared/state/terminals' import { listWorktrees, listBranches, continueWorktree, isWorktreeDirty, defaultWorktreeDir, getChangedFiles, getFileDiff, getBranchCommits, getCommitDiff, getCommitMeta, getCommitChangedFiles, getCommitFileDiffSides, getCommitRangeChangedFiles, getCommitRangeFileDiffSides, getMainWorktreeStatus, prepareMainForMerge, mergeWorktreeLocally, getBranchSha, previewMergeConflicts, getBranchDiffStats, listAllFiles, listRecentCommitShas, readWorktreeFile, readWorktreeFileBinary, writeWorktreeFile, getFileDiffSides, getCurrentBranch, renameWorktreeBranch, symlinkClaudeSettings, pruneWorktrees, type MergeStrategy } from './worktree' import { listOpenPRs, getPRByNumber, testToken, starRepo, unstarRepo, isRepoStarred, mergePR, approvePR, getRepoInfo, type GitHubMergeMethod, type MergePRResult, type PRLookupResult } from './github' import { AVAILABLE_EDITORS, DEFAULT_EDITOR_ID, openInEditor } from './editor' @@ -266,6 +266,18 @@ function findShellWorktree(shellId: string): string | null { } return null } + +/** True for a shell tab that was created with a command (`create_shell`) and + * whose process is gone. That command runs exactly once, in createShell's + * eager spawn; afterwards the tab is a transcript of the run, not a live + * shell — which is also how it behaves when the command exits mid-session. + * The renderer still fires pty:create when it mounts such a tab (it can't + * tell a spent shell from a fresh one), so the spawn is dropped here rather + * than handing back an interactive prompt no command shell would ever show. */ +function isSpentCommandShell(id: string): boolean { + const tab = findTabById(store.getSnapshot().state.terminals.panes, id) + return tab?.type === 'shell' && !!tab.command +} // Resolves the harness version from disk so it works in every runtime // (Electron dev/packaged, headless dev, headless tarball). Electron's // `app.getVersion()` would do the job in two of those four, but the @@ -3460,6 +3472,10 @@ function registerIpcHandlers(): void { : agentKind === 'cursor' ? config.cursorEnvVars : undefined const existed = ptyManager.hasTerminal(id) + if (!existed && isSpentCommandShell(id)) { + log('pty', `create id=${id} dropped — command shell already ran`) + return + } ptyManager.create(id, cwd, cmd, args, extraEnv, !isAgent, cols, rows) if (!existed) { // Creator becomes controller immediately so their first keystroke