Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added
- **Live-session hardening:** live sessions that end while no window is attached are archived at the next launch or `/resume`, with the real exit code or a note that the service stopped or the system restarted. Output captured while detached is kept. A frontend that loses its service reports it and archives the transcript. `/resume` shows how long each idle live session has been at its prompt.
- **Exactly-once archiving:** launch recovery, Kill Session and live journal checkpoints share a cross-process lock per journal, so concurrent launches never archive a session twice or overwrite a complete archive with partial state.
- **Fullscreen reattach:** reattaching to a running fullscreen app restores the terminal modes it had turned on, including mouse reporting and bracketed paste.
- **Safe updates:** each session-service protocol version has its own socket, so live sessions owned by an older service keep running after an update. A newer frontend never touches sessions it cannot verify, and it tells you they exist.
- **Session limit:** at most 16 live sessions per service (`NMSH_MAX_SESSIONS`). When the limit is reached, a new window falls back to an in-process shell with a notice. Detached sessions are never ended to make room.

### Removed
- `scripts/pty-history-smoke.mjs`: its checks had gone stale (it asserted retired UI text), it ran against the real config and live session service, and deterministic tests now cover everything it checked.

## [0.5.0] - 2026-09-28

Interaction & Intelligence: predictive suggestions, new layouts and presentations, a command palette, and a shared provider framework.
Expand Down
19 changes: 19 additions & 0 deletions docs/design/session-interaction-ux.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,25 @@ While a parent is running, its activity timeline is rendered at the end of the a

First-run onboarding asks for NMSh Native or Starship (NMSh is preselected), then the independent two-line/one-line composer choice. NMSh Native then shows an appearance step for theme, start, connector, gap, end, icons, and a module manager (Space shows/hides, Shift+↑↓ reorders, ←→ changes the exit-status condition; custom module colors are preserved), with a live preview from the real renderer and a one-row preview of every theme (the gallery is omitted on short terminals). Theme rows use the draft's real geometry over a synthetic preview-only context (project, cwd, git, Node, Go, Python, Docker) so every role is visible; the live prompt still shows only detected modules. The panel shows the saved configuration as `Current`, marks each changed value with its saved value, and labels the preview `unsaved preview` or `matches current`; nothing is applied until Enter saves. Every step ends with a consistent controls row listing its keys. Starship setup reports binary version and config path, supports existing/default configuration and truthful preset guidance, and offers an explicit Homebrew install confirmation on macOS when available. Both provider paths ask for composer layout. Escape can skip; completion and choices persist in the existing prompt config. `/prompt` reopens the same settings flow. Switching providers retains inactive provider settings and never edits Starship config or the user's ordinary `.zshrc`.

## Live-session recovery, updates and limits

Live sessions are owned by the per-user session service (`nmshd`), which listens on a Unix socket in a private (`0700`) runtime directory. Each protocol version has its own socket (`nmshd.sock` for v1, `nmshd-v<N>.sock` afterwards).

- **Updates:** after an update, the older service keeps running the sessions it owns until they end. A newer frontend starts or attaches only through a service that speaks its own protocol. While a service of another version is reachable, the newer frontend does not archive, attach to or end that service's sessions, because it cannot verify them. It says so once at launch.
- **Sessions that end with no window attached:** at launch, NMSh compares unfinished journals and output spools against the sessions its service reports. It archives each session that is gone so it appears in `/resume`, with a factual note:
- If the shell exited while detached, the note gives its exit code, and the output captured while detached is kept.
- If the service died or the machine restarted, the note says the shell and anything running in it could not be recovered.
Launch recovery and Kill Session share one ownership boundary. The journal is locked across processes (a lock left by a dead process is taken over), the spool is claimed by renaming it, and a journal that is already ended is never rewritten from a leftover spool. So a session is archived exactly once, and a second launch never overwrites a complete archive with a journal-only one. Live journal checkpoints take the same lock. If archiving fails, or a launch crashes while holding a claim, the spool is put back for the next launch.
- **Reattaching to a fullscreen app:** the service remembers the terminal modes the app turned on (mouse reporting, bracketed paste, application cursor keys and keypad, focus events, hidden cursor). A reattaching window re-applies them, so mouse and paste keep working in `vim`, `htop`, `less` and similar apps.
- **Service death under an attached window:** the frontend reports that it lost the service, archives the transcript, and exits. It never claims the session survived.
- **Session limit:** at most 16 live sessions per service (`NMSH_MAX_SESSIONS`). When the limit is reached, a new window falls back to an in-process shell with a notice. That window's shell ends when the window closes; it cannot be detached or reattached. Detached sessions are never ended to make room; end one, or kill it from `/resume`.
- **Idle age:** `/resume` shows how long each idle live session has been at its prompt.

Known limitations:
- Nothing running in a shell survives the service being killed or the machine restarting. Only the transcript and the output captured so far are kept.
- Sessions owned by an older service version can't be attached from a newer frontend. They stay listed as unverified until they end.
- Recovery runs at launch and when `/resume` opens. A session that ends while another window is open is archived the next time either of those happens.

## Current scope and planned follow-up

- Implemented onboarding configures prompt provider (NMSh Native, Starship, or Powerlevel10k), composer layout, and Native appearance (theme, start, connector, gap, end, icons, modules), with real-renderer previews. The next onboarding scope is optional tool discovery/setup for zoxide, fzf, and Atuin, offering Recommended / Choose individually / Skip. Any installer that modifies the system requires explicit confirmation. Starship remains an optional prompt provider. NMSh replaces the UI roles of zsh-autosuggestions and zsh-syntax-highlighting; neither is a required dependency. This v0.4 work is tracked in #9.
Expand Down
106 changes: 0 additions & 106 deletions scripts/pty-history-smoke.mjs

This file was deleted.

28 changes: 25 additions & 3 deletions src/app/TerminalApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ import {createResumeBrowser, describeLiveSession, navigateResume, resumeDayLabel
visibleLiveSessions, visibleResumeSessions, type ResumeBrowserState} from '../sessions/ResumeBrowser.js';
import {listLiveSessions} from '../session/connectSession.js';
import {killAndArchive} from '../session/liveSessions.js';
import {recoverEndedSessions} from '../session/recovery.js';
import {defaultRuntimeDir} from '../session/runtimeDir.js';

/** Editor text that marks interactive history search. */
const HISTORY_SEARCH = '/history ';
Expand Down Expand Up @@ -205,7 +207,15 @@ export class TerminalApp {
this.session.on('prompt', (marker, stamp) => { if (this.inStream(stamp)) this.onShellPrompt(marker.exitCode, marker.cwd, stamp.at); });
this.session.on('exec', (command, stamp) => { if (this.inStream(stamp)) this.onShellExec(command, stamp.at); });
this.session.on('replayed', summary => this.finishReplay(summary));
this.session.on('exit', event => { this.shellEnded = true; this.stop(event.exitCode); });
this.session.on('exit', event => {
this.shellEnded = true;
if (event.lost) {
// Recorded in the journal before it closes; nothing claims the shell survived.
this.lostServiceConnection = true;
this.output.addFrontendInteraction('session', 'Lost the connection to the NMSh session service; this live session has ended.', ERROR);
}
this.stop(event.exitCode);
});
this.sessionMode = connection?.mode ?? 'in-process';
this.sessionId = connection?.sessionId;
if (connection?.attached) this.beginReattach(connection.attached, connection.journal);
Expand All @@ -218,6 +228,8 @@ export class TerminalApp {
private replaying = false;
/** The shell itself ended; the journal is no longer linked to a live session. */
private shellEnded = false;
/** Set when the service vanished under an attached session; reported after the screen is restored. */
lostServiceConnection = false;
/** Commands the replay completed, for the reattach summary. */
private replayedCompletions = 0;
private attachedSession?: AttachedSession;
Expand Down Expand Up @@ -278,15 +290,21 @@ export class TerminalApp {
if (!this.running && attached.running) this.onShellExec(attached.running, attached.runningSince);
if (this.running && (attached.fullscreen !== 0 || shouldPassthrough(this.running.command))) {
this.passthrough = true;
this.attachedModes = attached.modes ?? '';
if (this.rendererEntered) this.enterAttachedPassthrough();
}
this.scheduleJournal();
this.render();
}

/** The reattached fullscreen app's own terminal modes, which this terminal never received. */
private attachedModes = '';

private enterAttachedPassthrough(): void {
// Reattached into a fullscreen app: hand it the whole terminal again.
this.renderer.suspendForPassthrough();
// Reattached into a fullscreen app: hand it the whole terminal again,
// including the mouse/paste/cursor-key modes it set before the detach.
this.renderer.suspendForPassthrough(this.attachedModes);
this.attachedModes = '';
const dimensions = this.dimensions();
this.session.resize(dimensions.columns, dimensions.rows);
}
Expand Down Expand Up @@ -1215,6 +1233,10 @@ export class TerminalApp {

private async openResumePicker(): Promise<void> {
try {
// Anything that ended while no window watched is archived before listing.
if (this.sessionMode === 'service') {
try { await recoverEndedSessions(defaultRuntimeDir(), this.transcriptStore); } catch { /* best effort */ }
}
const sessions = (await this.transcriptStore.listSummaries()).filter(session => session.id !== this.journal?.id);
// LIVE comes from the service itself, so a dead shell is never listed as live.
let live: SessionInfo[] = [];
Expand Down
19 changes: 18 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,20 @@ if (isVersionInvocation(args)) {
let target: string | undefined = attachIndex === -1 ? undefined : args[attachIndex + 1];
let explicit = target !== undefined;
let notice: string | undefined;
if (process.env[SESSION_SERVICE_ENV] !== '0') {
// Sessions that ended with no window attached become ordinary archives.
const {recoverEndedSessions} = await import('./session/recovery.js');
const {defaultRuntimeDir} = await import('./session/runtimeDir.js');
const {TranscriptStore} = await import('./sessions/TranscriptStore.js');
try {
const recovered = await recoverEndedSessions(defaultRuntimeDir(), new TranscriptStore());
if (recovered.archived.length > 0) {
notice = `${recovered.archived.length} live session${recovered.archived.length === 1 ? '' : 's'} ended while no NMSh window was attached; see /resume.`;
} else if (recovered.skipped === 'another NMSh session service version is running') {
notice = 'A session service from another NMSh version is still running its own live sessions; they continue until they end but cannot be attached from this version.';
}
} catch { /* recovery is best effort and never blocks launch */ }
}
if (!explicit && !args.includes('--new') && process.env[SESSION_SERVICE_ENV] !== '0') {
let live: Awaited<ReturnType<typeof listLiveSessions>> = [];
try { live = await listLiveSessions(); } catch { /* no usable service: start fresh */ }
Expand Down Expand Up @@ -90,10 +104,13 @@ if (isVersionInvocation(args)) {
if (notice) connection = {...connection, notice: [connection.notice, notice].filter(Boolean).join(' ')};
const app = new TerminalApp(connection);
const exitCode = await app.run();
if (app.lostServiceConnection) {
process.stderr.write('NMSh lost the connection to its session service; the live session ended and its transcript was archived.\n');
}
notice = undefined;
if (app.switchTarget) {
target = app.switchTarget;
explicit = false;
notice = undefined;
continue;
}
process.exitCode = app.isOrdinaryZshHandoffRequested
Expand Down
5 changes: 4 additions & 1 deletion src/session/SessionClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ export interface SessionClientEvents {
/** The backlog sent after a reattach has been delivered. */
replayed: [{truncatedBytes: number}];
/** The managed shell ended. */
exit: [{exitCode: number; signal?: number}];
/** lost: the connection to the service dropped without the shell's exit being reported. */
exit: [{exitCode: number; signal?: number; lost?: boolean}];
}

/**
Expand Down Expand Up @@ -54,6 +55,8 @@ export interface AttachedSession {
cwd: string;
/** Nonzero while the foreground app holds the alternate screen. */
fullscreen: number;
/** Terminal input modes the fullscreen app set (mouse, bracketed paste, ...), to restore on reattach. */
modes?: string;
running?: string;
runningSince?: number;
/** Journal the previous frontend kept for this session, and how far it got. */
Expand Down
Loading
Loading