diff --git a/CHANGELOG.md b/CHANGELOG.md index d3e165a6..ced175ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Markdown editing helpers in `/edit`** ([#463](https://github.com/raiseCatError/notMyShell/issues/463), first slice; Markdown files only). **Enter** continues a bullet, numbered or task list (same marker and indentation, next number, unchecked box) and splits an item at the caret; on an empty item it ends the list (a nested one steps out a level). **Tab / Shift+Tab** on list items indent or outdent the item with its children (selected items together); elsewhere Tab is a tab as before. **F1** opens the Markdown actions: toggle a task checkbox, renumber the list at the caret, insert a link or image around the selection, wrap the selection in bold, italic, code or strikethrough (again to unwrap). Every action changes only the lines it names and is one undo step; text is never reflowed or regenerated. - **Crash recovery for `/edit` and the document workspace** ([#469](https://github.com/raiseCatError/notMyShell/issues/469)). Unsaved changes are written, a moment after you stop typing, to a private draft in NMSh's data folder (`recovery/`, mode 0700; files 0600). After a crash, a kill or a closed window, opening the file offers **Restore draft**, **Compare with file**, **Discard draft** or **Keep for later**; nothing is applied until you choose, a draft made from an older version of the file says so, and the file itself is only ever written by the editor's normal checked save (which also retires the draft). Two windows on one file keep separate drafts, and a draft of a window that is still running is never offered. Drafts are bounded (2 MiB each, 50 in all, 30 days) and are never sent anywhere. +- **Session lifecycle and cleanup** ([design](docs/design/session-interaction-ux.md#session-lifecycle-and-cleanup)). A detached session that never ran a command ends after 10 minutes detached (Config → Sessions → *Clean up unused sessions*: Off, 10 minutes, 30 minutes, 1 hour). Right before, the service checks again that it is still detached, unused, unprotected and holds no work or child processes, and the transcript is archived with the reason. Sessions idle for over a day are only suggested for review; *End idle sessions* (Off by default) can end them after 1, 3 or 7 days. Attached sessions, and sessions running a command, waiting for input, holding queued commands, background tasks, shell jobs or child processes, are never ended by a timer. Closing a window or a Rex pane only detaches. The startup picker counts working, waiting and idle sessions, tags cleanup status, and adds **E** End All Eligible (lists exactly the eligible sessions; the service re-verifies each), **I** Review Idle Sessions, **P** Protect and **C** cleanup settings. An explicit end of a shell that ignores SIGHUP now completes (SIGKILL after 3 s, only to a pid that is still the service's own child). Sessions created before the service heard a policy are never ended by a timer. + +### Changed + +- **Startup session picker** ([design](docs/design/session-interaction-ux.md)). With several detached sessions, Enter now resumes the highlighted session when none is selected (it used to start a new one), N or Esc starts a new session, and the footer says what Enter and X will act on. X kills the selected sessions, or the highlighted one: the confirmation names the count and lists them, so A then X is Kill all; every target is listed by name (never "and N more"); sessions attached in another window, or attached while the confirmation was open, are never touched, and a line reports what happened to each, failures first. A service that cannot be reached is reported as a failure, never as "already ended"; a session this window ended whose transcript could not be archived yet says so (the spool is kept and the next launch archives it); and a session being ended can no longer be attached by another window in the moment before its shell exits. In the one-session prompt, a kill the service refused or could not perform returns to the prompt with the reason instead of starting fresh as if it had worked. Rows and the one-session prompt show the session's name and shell and the same evidence-based status as `/resume` (idle, what runs, waiting for input) instead of always saying `zsh`, and the screen redraws on resize. + ## [0.23.0] - 2026-10-21 — Vespyr Arcade The Arcade grows from five games to 33, and it can now be played while a command keeps running. diff --git a/docs/design/session-interaction-ux.md b/docs/design/session-interaction-ux.md index 029fff7c..be200bee 100644 --- a/docs/design/session-interaction-ux.md +++ b/docs/design/session-interaction-ux.md @@ -135,11 +135,11 @@ Live sessions are owned by the per-user session service (`nmshd`), which listens - **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`. - **Startup restore:** two settings in Config → Sessions. - **Startup restore:** Ask (default), Always or Never. - - Ask: with one detached session, the launch shows its directory, what it is running and its age, then offers Resume (R/Enter), Not now (N/Esc), Always resume (A) or Don't resume at startup (D). A and D also save the setting. + - Ask: with one detached session, the launch shows its name, directory and shell, and the same evidence-based status as `/resume` (idle or what it runs, for how long, whether it waits for input), then offers Resume (R/Enter), Not now (N/Esc), Always resume (A) or Don't resume at startup (D). A and D also save the setting. - Never only skips restoring at launch. It never ends a session, and `/resume`, detach and reattach are unaffected. - **Multiple detached sessions:** Ask which (default) or Open all. - - The picker uses ↑↓ to move, Space to select, A to select all (A again clears), and Enter to resume the selected sessions. Esc, or Enter with nothing selected, starts fresh. - - Both screens have one destructive key, `X`, which only opens a confirmation (Enter/Y kills, Esc/N/Ctrl+C cancels). The kill is the same confirmed termination `/resume` uses; the transcript is archived and stays in `/resume`. Sessions attached elsewhere are never touched, and killing the last one starts a fresh session. `--new` still skips restoring, and `--attach ` attaches one session explicitly. + - Each row shows the session's name, directory, shell and status. The picker uses ↑↓ to move, Space to select, A to select all (A again clears), and Enter to resume the selected sessions, or the highlighted one when none is selected; the footer says which ("Enter resume this", "Enter resume 2 selected"). N or Esc starts a new session and leaves every listed one running. The screen redraws when the terminal is resized. + - Both screens have one destructive key, `X`, which only opens a confirmation (Enter/Y kills, Esc/N/Ctrl+C cancels). In the picker it targets the selected sessions, or the highlighted one when none is selected; the confirmation names how many and lists them, so A then X is Kill all. Every target is listed by name and nothing else is ended. Each kill is the same confirmed termination `/resume` uses, performed one at a time and addressed by the service's session id (never a PID): the service's list is read again first, and the service itself refuses the kill if a window holds the session at that moment. The transcript is archived and stays in `/resume`; if archiving fails after the shell ended, the line says so and the spool is kept for the next launch. Once a kill is sent the service refuses to attach that session until its shell exits. An unreachable service is reported as a failure, never as "already ended". Sessions attached elsewhere are never touched (a line says how many were left), and killing the last one starts a fresh session. Launching or quitting NMSh never ends a session by itself. `--new` still skips restoring, and `--attach ` attaches one session explicitly. - When several sessions are restored, this window attaches the first. Each other one opens in a new window of the host terminal, running `nmsh --attach `: - Ghostty on macOS: Ghostty's AppleScript API (`new surface configuration`, then `new window with configuration`). It opens a normal window in the running Ghostty app. The command words are passed as `osascript` arguments and shell-quoted with `quoted form of`, never written into the script. macOS asks once for Automation permission. If AppleScript is disabled or permission is denied, NMSh names the session's `nmsh --attach` command instead. - Ghostty on Linux: `ghostty -e`. @@ -160,6 +160,49 @@ Known limitations: - 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. +## Session lifecycle and cleanup + +The service (`nmshd`) owns every live session; a window only controls one. Rex, Ghostty or any other host keeps its own panes and sessions, and NMSh never infers intent from them: a host pane that goes away is a lost connection, never a request to end anything. One pure function, `eligibility()` in `src/session/lifecycle.ts`, decides from facts the service observed, and both the service and every surface read the same answer. + +States, from observed facts only: + +| State | Facts | +|---|---| +| attached | a window controls it | +| executing (detached) | a command runs, a fullscreen program holds the screen, the shell is still in its startup (an rc file waiting on a question), or there are queued commands, background tasks or shell jobs | +| waiting (detached) | the running command credibly waits for input | +| idle (detached) | at its prompt, has run at least one command | +| unused (detached) | at its prompt and never ran a command, queued one or started a task | +| protected | marked with P in the picker; an overlay on any state | +| ending | a confirmed end was sent; the shell has not exited yet (nothing may attach) | +| ended / stale | the service no longer lists it; its spool and journal are archived at the next launch or `/resume` (by service listing, never by PID) | +| unknown | the service cannot be reached, or another protocol version owns it: nothing is touched | + +Idle output never makes a session disposable. Child processes are read with one bounded `pgrep -P ` only when cleanup is about to act; the shell is the service's own PTY child, and a shell switch during the read is detected. If the read fails, the session is kept. + +Policy: + +| Event | What happens | Automatic? | +|---|---|---| +| Window or Rex pane closes, host quits, frontend crashes or is SIGKILLed | Detach. The session keeps running. | Yes (never ends it) | +| `/exit`, `/zsh`, `/fish`, `/bash` | Detach and hand off; `nmsh` from that shell returns to it. | Yes (never ends it) | +| Picker N or Esc (Start New / Leave Running) | Every listed session keeps running. | Yes | +| Unused, detached for the grace period (Config → Sessions → Clean up unused sessions: Off, 10 min default, 30 min, 1 h) | Ended after re-verifying, right before, that it is still detached, unused, unprotected, holds no work and has no child processes. Its transcript is archived with the reason. | Yes, only for sessions created after a policy reached the service | +| Used, idle, detached for a day | Suggested: the picker tags it and offers I (Review Idle Sessions) and E; a launch without the picker shows one line. | No: needs confirmation | +| Idle expiry (Config → Sessions → End idle sessions: Off by default, 1, 3 or 7 days) | Ended after the same re-verification. | Only if the person turned it on | +| Attached, executing, waiting, queued, tasks, jobs, child processes | Never ended by a timer. | Protected by the rules | +| Protected (P) | Never ended by cleanup or End All Eligible; X still ends it after its confirmation. | Protected | +| X (End Selected) | The confirmation lists every target by name; each is re-read from the service and ended by id; attached ones are never touched. | Needs confirmation | +| E (End All Eligible) | Lists exactly the eligible sessions with their reasons; the service re-verifies each (work, protection, children) and keeps any that changed. | Needs confirmation | +| A shell ignores SIGHUP on an explicit end | After 3 s the service sends SIGKILL to that shell, only after confirming its pid is still the service's own child (a reaped or reused pid is left alone). | Part of the confirmed end | +| Shell exits on its own | The session ends; detached, the next launch archives it. | Yes | +| Service crash or SIGKILL | Shells lose their PTY and end; the next launch archives each with a "service stopped" note. No automatic restart. | Yes | +| Service SIGTERM/SIGHUP | Ends every shell and task, archives on next launch. | Yes | + +Upgrades: the policy reaches the service in each frontend's hello (`unused=;idle=`, bounded). A session created before any frontend stated a policy is never ended by a timer, and an older frontend that states none changes nothing. Each protocol version has its own service, so sessions owned by an older service keep that service's behaviour until they end. A configuration without these settings gets unused cleanup at 10 minutes and idle expiry off. + +Timers are event driven: one timer per detached session, armed on detach, prompt, policy or protection change, at the moment it could become eligible, cleared on attach, exit and end. A detached session holding work is looked at again at most hourly. + ## 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. diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 2cbfa17a..a751e5c8 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -377,6 +377,8 @@ import {createResumeBrowser, describeArchivedRow, describeLiveRow, LIVE_ROW_LABE import {dismissSessionNotice, listLiveSessions, listSessionNotices} from '../session/connectSession.js'; import {OLDER_SERVICE_SWITCH, serviceBuildNotice, serviceInputNotice} from '../session/SocketSessionClient.js'; import {killAndArchive} from '../session/liveSessions.js'; +import {setHelloLifecycle} from '../session/SocketSessionClient.js'; +import {encodePolicy, policyFromSettings} from '../session/lifecycle.js'; import {recoverEndedSessions} from '../session/recovery.js'; import {AgentSessions, normalizeProfiles, type AgentProfile} from '../agents/sessions/manager.js'; import type {AgentSession} from '../agents/sessions/model.js'; @@ -5115,6 +5117,12 @@ export class TerminalApp { this.stop(0); } + /** Session Lifecycle settings changed: say so in every hello from now on, and tell the service at once with one listing. */ + private statePolicyToService(): void { + setHelloLifecycle(encodePolicy(policyFromSettings(this.promptConfiguration))); + if (this.sessionMode === 'service') void listLiveSessions().catch(() => undefined); + } + private async killSelectedLiveSession(): Promise { const browser = this.resumeBrowser; const target = browser?.live.find(session => session.id === browser.confirmKill); @@ -6261,7 +6269,9 @@ export class TerminalApp { state.draft.onboardingComplete = true; try { savePromptConfiguration(state.draft, undefined, this.promptConfiguration); + const lifecycleChanged = state.draft.sessionCleanup !== this.promptConfiguration.sessionCleanup || state.draft.sessionIdleExpiry !== this.promptConfiguration.sessionIdleExpiry; this.promptConfiguration = structuredClone(state.draft); + if (lifecycleChanged) this.statePolicyToService(); this.promptPanelState = undefined; if (state.onboarding && !this.promptConfiguration.toolsSetupComplete) this.startTools(true); this.panelExternalPrompt = undefined; diff --git a/src/configuration/portability.ts b/src/configuration/portability.ts index b573f2e6..c0beae55 100644 --- a/src/configuration/portability.ts +++ b/src/configuration/portability.ts @@ -34,7 +34,7 @@ export const PORTABLE_CATEGORIES = { idle: ['idleVisuals', 'liveActivity'], notifications: ['notifications', 'sessionNotices'], tools: ['toolUpdateChecks', 'installSuggestions', 'ignoredInstallSuggestions', 'updateMode', 'updateFrequency'], - sessions: ['liveSessionStartup', 'liveSessionMultiple', 'sessionRetention', 'terminalTitle', 'commandQueue', 'queueFailurePolicy', 'queue'], + sessions: ['liveSessionStartup', 'liveSessionMultiple', 'sessionCleanup', 'sessionIdleExpiry', 'sessionRetention', 'terminalTitle', 'commandQueue', 'queueFailurePolicy', 'queue'], agents: ['agentActivity'], shell: ['shellBackend'], editor: ['openWith'], diff --git a/src/index.ts b/src/index.ts index 244c5c9b..b3b859ae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -138,13 +138,21 @@ if (args[0] === 'packs') { let target: string | undefined = attachIndex === -1 ? undefined : args[attachIndex + 1]; let explicit = target !== undefined; let notice: string | undefined; + let endedWhileAway = 0; if (process.env[SESSION_SERVICE_ENV] !== '0') { + // Every request this launch makes states the person's session cleanup policy; an unreadable configuration states none. + try { + const [{setHelloLifecycle}, {encodePolicy, policyFromSettings}, {loadPromptConfiguration}] = await Promise.all([ + import('./session/SocketSessionClient.js'), import('./session/lifecycle.js'), import('./prompt/configuration.js')]); + setHelloLifecycle(encodePolicy(policyFromSettings(loadPromptConfiguration()))); + } catch { /* the service then keeps whatever policy it already has, or none */ } // 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()); + endedWhileAway = recovered.archived.length; 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') { @@ -174,16 +182,14 @@ if (args[0] === 'packs') { const config = loadPromptConfiguration(); // Same confirmed termination as /resume. Service truth is re-read first: a session that ended // meanwhile is simply gone, and one attached elsewhere is never touched. - const killDetached = async (session: (typeof live)[number]): Promise<'killed' | 'gone' | 'attached'> => { - const [{killAndArchive}, {TranscriptStore}] = await Promise.all([import('./session/liveSessions.js'), import('./sessions/TranscriptStore.js')]); - const current = (await listLiveSessions().catch(() => [])).find(item => item.id === session.id); - if (!current) return 'gone'; - if (current.state !== 'detached') return 'attached'; - try { await killAndArchive(current, {store: new TranscriptStore(), origin: 'the startup picker'}); return 'killed'; } - catch (error) { - if (!(await listLiveSessions().catch(() => [current])).some(item => item.id === session.id)) return 'gone'; - throw error; - } + // An unreachable service is a failure, never "already ended"; only a shell this call ended counts as killed. + const killDetached = async (session: (typeof live)[number], mode?: 'eligible') => { + const [{endDetachedSession, archiveKilled}, {killSession}, {defaultRuntimeDir, socketPathFor}, {TranscriptStore}] = await Promise.all([ + import('./session/liveSessions.js'), import('./session/SocketSessionClient.js'), import('./session/runtimeDir.js'), import('./sessions/TranscriptStore.js')]); + const store = new TranscriptStore(); + return endDetachedSession(session, {list: () => listLiveSessions(), + kill: current => killSession(socketPathFor(defaultRuntimeDir()), current.id, 5000, mode), + archive: current => archiveKilled(current, {store, origin: 'the startup picker'})}); }; const restored = await restoreAtStartup(live, { // After a failed return, never silently attach a different session: ask instead of Always. @@ -192,12 +198,34 @@ if (args[0] === 'packs') { try { const base = loadPromptConfiguration(); savePromptConfiguration({...base, liveSessionStartup: startup}, undefined, base); } catch { /* keep going; applies this launch */ } }, askOne: session => picker.askWithKill(session, {run: picker.runStartupScreen, kill: killDetached}), - pick: sessions => picker.pickWithKill(sessions, {run: picker.runStartupScreen, kill: killDetached}), + pick: sessions => picker.pickWithKill(sessions, {run: picker.runStartupScreen, kill: killDetached, + context: {attachedElsewhere: live.filter(session => session.state === 'attached').length, endedWhileAway}, + protect: async (session, on) => { + const [{protectSession}, {defaultRuntimeDir, socketPathFor}] = await Promise.all([import('./session/SocketSessionClient.js'), import('./session/runtimeDir.js')]); + return protectSession(socketPathFor(defaultRuntimeDir()), session.id, on); + }, + configure: async () => { + const base = loadPromptConfiguration(); + const state = {draft: {sessionCleanup: base.sessionCleanup, sessionIdleExpiry: base.sessionIdleExpiry}, cursor: 0 as 0 | 1}; + const chosen = await picker.runStartupScreen(columns => picker.renderCleanupSettings(state, columns), key => picker.cleanupSettingsKey(state, key)); + if (!chosen) return undefined; + savePromptConfiguration({...base, ...chosen}, undefined, base); + const [{setHelloLifecycle}, {encodePolicy, policyFromSettings}] = await Promise.all([import('./session/SocketSessionClient.js'), import('./session/lifecycle.js')]); + setHelloLifecycle(encodePolicy(policyFromSettings(chosen))); + return 'Cleanup settings saved; the session service applies them now.'; + }, + refresh: () => listLiveSessions()}), host: detectTerminalHost(), selfCommand: [process.execPath, ...process.execArgv.filter(arg => !arg.startsWith('--inspect')), process.argv[1]!], }); target = restored.target; notice = [notice, restored.notice].filter(Boolean).join(' ') || undefined; + // Long-idle detached sessions are only ever suggested; the picker shows them itself, so this is for launches without it. + const {planLaunch} = await import('./session/liveSessions.js'); + const longIdle = live.filter(session => session.state === 'detached' && session.lifecycle === 'idle' && session.eligibility === 'review' && session.id !== target); + if (longIdle.length && planLaunch(live, {startup: config.liveSessionStartup, multiple: config.liveSessionMultiple}).kind !== 'pick') { + notice = [notice, `${longIdle.length} detached session${longIdle.length === 1 ? ' has' : 's have'} been idle for over a day; /sessions to review (nothing is ended unless you choose).`].join(' ').trim(); + } } // The loop lets /resume switch this window to another live session. diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index fe4f2a8a..d1484cee 100644 --- a/src/prompt/configuration.ts +++ b/src/prompt/configuration.ts @@ -11,6 +11,12 @@ export const LIVE_SESSION_STARTUP = ['ask', 'always', 'never'] as const; export type LiveSessionStartup = typeof LIVE_SESSION_STARTUP[number]; export const LIVE_SESSION_MULTIPLE = ['ask', 'open-all'] as const; export type LiveSessionMultiple = typeof LIVE_SESSION_MULTIPLE[number]; +/** End a detached session that never ran a command after this long (Session Lifecycle; see session/lifecycle.ts). */ +export const SESSION_CLEANUP = ['off', '10m', '30m', '1h'] as const; +export type SessionCleanup = typeof SESSION_CLEANUP[number]; +/** End a used, idle detached session after this long without activity; off by default. */ +export const SESSION_IDLE_EXPIRY = ['off', '1d', '3d', '7d'] as const; +export type SessionIdleExpiry = typeof SESSION_IDLE_EXPIRY[number]; import type {OutputFoldingMode} from '../output/FoldPolicy.js'; import type {NavigationProviderId} from '../shell/DirectoryService.js'; import type {PickerProviderId} from '../pickers/Picker.js'; @@ -641,6 +647,10 @@ export interface PromptConfiguration { liveSessionStartup: LiveSessionStartup; /** With several detached live sessions at launch: ask which, or open them all. */ liveSessionMultiple: LiveSessionMultiple; + /** Session Lifecycle: end never-used detached sessions after this grace; only sessions created after the policy reached the service. */ + sessionCleanup: SessionCleanup; + /** Session Lifecycle: end used idle detached sessions after this long; off by default. */ + sessionIdleExpiry: SessionIdleExpiry; /** Whether long, boring finished output starts collapsed. Presentation only. */ outputFolding: OutputFoldingMode; /** Semantic Output: a test or build summary drawn in recognized blocks (#386). Presentation only; Off draws raw only. */ @@ -906,6 +916,8 @@ export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = { updateFrequency: 'daily', liveSessionStartup: 'ask', liveSessionMultiple: 'ask', + sessionCleanup: '10m', + sessionIdleExpiry: 'off', notifications: {...DEFAULT_NOTIFICATION_SETTINGS}, outputFolding: 'smart', semanticOutput: true, @@ -1034,6 +1046,8 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio ? value.liveSessionStartup as LiveSessionStartup : 'ask'; const liveSessionMultiple: LiveSessionMultiple = LIVE_SESSION_MULTIPLE.includes(value.liveSessionMultiple as LiveSessionMultiple) ? value.liveSessionMultiple as LiveSessionMultiple : 'ask'; + const sessionCleanup: SessionCleanup = SESSION_CLEANUP.includes(value.sessionCleanup as SessionCleanup) ? value.sessionCleanup as SessionCleanup : '10m'; + const sessionIdleExpiry: SessionIdleExpiry = SESSION_IDLE_EXPIRY.includes(value.sessionIdleExpiry as SessionIdleExpiry) ? value.sessionIdleExpiry as SessionIdleExpiry : 'off'; // Off persists as `never`, so v0.4 configs load unchanged. const outputFolding: OutputFoldingMode = value.outputFolding === 'never' || value.outputFolding === 'always' ? value.outputFolding : 'smart'; const semanticOutput = value.semanticOutput !== false; @@ -1121,7 +1135,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio if (!Array.isArray(value.modules)) { return {...structuredClone(DEFAULT_PROMPT_CONFIGURATION), provider, onboardingComplete: value.onboardingComplete === true, - toolsSetupComplete, glyphStyle, glyphChoiceComplete, onboardingBackdrop, sessionRetention, updateMode, updateFrequency, liveSessionStartup, liveSessionMultiple, outputFolding, semanticOutput, welcome, suggestions, history, picker, navigation, suggestionsOnEmpty, + toolsSetupComplete, glyphStyle, glyphChoiceComplete, onboardingBackdrop, sessionRetention, updateMode, updateFrequency, liveSessionStartup, liveSessionMultiple, sessionCleanup, sessionIdleExpiry, outputFolding, semanticOutput, welcome, suggestions, history, picker, navigation, suggestionsOnEmpty, presentation, nmsh, starship: {configPath: starshipConfigPath}, powerlevel10k, ohMyPosh, transcript, syntax, notifications, placement, composerLayout, composerPosition, panelPosition, transcriptPresentation, composerDividers: value.composerDividers !== false, spacing, gap, separator, ...themed}; } @@ -1181,7 +1195,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio return {provider, onboardingComplete: value.onboardingComplete === true, toolsSetupComplete, - glyphStyle, glyphChoiceComplete, onboardingBackdrop, sessionRetention, updateMode, updateFrequency, liveSessionStartup, liveSessionMultiple, outputFolding, semanticOutput, welcome, suggestions, history, picker, navigation, suggestionsOnEmpty, presentation, nmsh, transcript, syntax, notifications, powerlevel10k, ohMyPosh, + glyphStyle, glyphChoiceComplete, onboardingBackdrop, sessionRetention, updateMode, updateFrequency, liveSessionStartup, liveSessionMultiple, sessionCleanup, sessionIdleExpiry, outputFolding, semanticOutput, welcome, suggestions, history, picker, navigation, suggestionsOnEmpty, presentation, nmsh, transcript, syntax, notifications, powerlevel10k, ohMyPosh, starship: {configPath: starshipConfigPath}, placement, composerLayout, composerPosition, panelPosition, transcriptPresentation, composerDividers: value.composerDividers !== false, modules, separator, spacing, gap, ...themed}; } diff --git a/src/session/SessionProtocol.ts b/src/session/SessionProtocol.ts index ea0132e8..55c61a40 100644 --- a/src/session/SessionProtocol.ts +++ b/src/session/SessionProtocol.ts @@ -26,7 +26,7 @@ export const MAX_FRAME_BYTES = 8 * 1024 * 1024; * talking to an older service (same protocol version, still running its live * sessions) degrades factually instead of failing later. */ -export const SERVICE_FEATURES = ['shell-switch', 'shell-backends', 'notices', 'input-state', 'queue', 'queue-conditions', 'background-tasks', 'input-look', 'queue-timeline', 'queue-history', 'input-look-result', 'deferred-attach-resize'] as const; +export const SERVICE_FEATURES = ['shell-switch', 'shell-backends', 'notices', 'input-state', 'queue', 'queue-conditions', 'background-tasks', 'input-look', 'queue-timeline', 'queue-history', 'input-look-result', 'deferred-attach-resize', 'lifecycle'] as const; export type ServiceFeature = typeof SERVICE_FEATURES[number]; /** @@ -47,7 +47,8 @@ export function parseFeatures(text: string | undefined): Set { export type ClientMessage = /** features: comma-separated CLIENT_FEATURES this frontend understands; absent from older frontends. */ - | {type: 'hello'; version: number; client: string; features?: string} + /** lifecycle: the person's session cleanup policy (`unused=;idle=`, bounded); absent from older frontends. */ + | {type: 'hello'; version: number; client: string; features?: string; lifecycle?: string} /** shell: backend id (zsh, fish, bash); absent means zsh (older frontends). */ | {type: 'create'; cwd: string; env: Record; columns: number; rows: number; shell?: string} | {type: 'attach'; sessionId: string; columns: number; rows: number} @@ -59,7 +60,10 @@ export type ClientMessage = /** Everything up to seq is durable in the frontend journal journalId. */ | {type: 'ack'; seq: number; journalId: string} /** End a detached session this connection does not control (Kill Session). */ - | {type: 'kill'; sessionId: string} + /** when='eligible': End All Eligible; the service re-verifies eligibility (work, protection, children) and refuses with 'busy'. Only to services that advertised 'lifecycle'. */ + | {type: 'kill'; sessionId: string; when?: string} + /** Protect (on=1) or unprotect a live session: protected sessions are never ended by cleanup or End All Eligible. Only to services that advertised 'lifecycle'. */ + | {type: 'protect'; sessionId: string; on: number} /** Clear a session's cross-session notice everywhere (it was opened/focused). */ | {type: 'dismiss'; sessionId: string} /** Replace this session's shell backend in place, starting it in cwd. Refused while anything would be lost. */ @@ -175,6 +179,21 @@ export interface SessionInfo { /** Commands queued in this session, and why they wait, if paused (only to frontends that listed 'queue'). */ queued?: number; queuePaused?: string; + // Lifecycle (services that advertise 'lifecycle'); the service's own reading of its facts, never a guess here. + /** attached, ending, executing, waiting, unused or idle. */ + lifecycle?: string; + /** Why it is kept, or why cleanup would apply. */ + cleanup?: string; + /** automatic, review or kept (see lifecycle.ts). */ + eligibility?: string; + /** When the service's pending check would end it, if nothing changes. */ + cleanupAt?: number; + used?: number; + detachedSince?: number; + tasks?: number; + jobs?: number; + protected?: number; + ending?: number; } export type ProtocolMessage = ClientMessage | ServerMessage; @@ -191,7 +210,7 @@ type Kind = 'string' | 'int' | 'env' | 'int?' | 'string?' | 'sessions' | 'notice type Shape = Record; const SHAPES: Record = { - hello: {version: 'int', client: 'string', features: 'string?'}, + hello: {version: 'int', client: 'string', features: 'string?', lifecycle: 'string?'}, create: {cwd: 'string', env: 'env', columns: 'int', rows: 'int', shell: 'string?'}, 'switch-shell': {shell: 'string', cwd: 'string'}, rename: {sessionId: 'string', name: 'string'}, @@ -202,7 +221,8 @@ const SHAPES: Record = { input: {data: 'string', submission: 'int?'}, resize: {columns: 'int', rows: 'int', repaint: 'int?'}, ack: {seq: 'int', journalId: 'string'}, - kill: {sessionId: 'string'}, + kill: {sessionId: 'string', when: 'string?'}, + protect: {sessionId: 'string', on: 'int'}, dismiss: {sessionId: 'string'}, queue: {change: 'string'}, 'input-look': {id: 'int'}, @@ -239,7 +259,9 @@ function isEnv(value: unknown): value is Record { const INFO_SHAPE: Shape = {id: 'string', pid: 'int', state: 'string', cwd: 'string', createdAt: 'int', running: 'string?', runningSince: 'int?', idleSince: 'int?', journalId: 'string?', process: 'string?', fullscreen: 'int?', lastOutputAt: 'int?', title: 'string?', attentionSince: 'int?', lastExit: 'int?', notice: 'notice?', shell: 'string?', signature: 'string?', name: 'string?', - inputSince: 'int?', inputConfidence: 'string?', inputMode: 'string?', inputPrompt: 'string?', queued: 'int?', queuePaused: 'string?'}; + inputSince: 'int?', inputConfidence: 'string?', inputMode: 'string?', inputPrompt: 'string?', queued: 'int?', queuePaused: 'string?', + lifecycle: 'string?', cleanup: 'string?', eligibility: 'string?', cleanupAt: 'int?', used: 'int?', detachedSince: 'int?', tasks: 'int?', jobs: 'int?', + protected: 'int?', ending: 'int?'}; const NOTICE_SHAPE: Shape = {sessionId: 'string', kind: 'string', at: 'int', program: 'string?', agent: 'string?', exitCode: 'int?', durationMs: 'int?', cwd: 'string?', confidence: 'string?'}; diff --git a/src/session/SessionService.ts b/src/session/SessionService.ts index 4c7b459d..2823ae32 100644 --- a/src/session/SessionService.ts +++ b/src/session/SessionService.ts @@ -17,6 +17,8 @@ import {SESSION_ID_ENV, SESSION_MODE_ENV} from './SessionClient.js'; import {ensurePrivateRuntimeDir, socketPathFor, spoolPathFor} from './runtimeDir.js'; import {EndedNotices, INPUT_NOTICE_DELAY_MS, SessionNoticeTracker} from './SessionNotices.js'; import {StreamBacklog, type BacklogEvent, type BacklogLimits} from './StreamBacklog.js'; +import {decodePolicy, eligibility, lifecycleState, type LifecycleFacts, type LifecyclePolicy} from './lifecycle.js'; +import {execFile} from 'node:child_process'; export const SERVICE_NAME = 'nmshd'; @@ -31,6 +33,8 @@ export interface SessionRecord { signature?: string; /** The person's own name for the session, when they renamed it. */ name?: string; + /** Protected: never ended by cleanup or End All Eligible (an explicit End still asks and works). */ + protected?: true; } type Send = (message: ServerMessage) => void; @@ -72,6 +76,20 @@ interface ManagedSession { tasks: SessionTasks; /** The attached frontend's task listener, when it listed 'background-tasks'. */ taskListener?: TaskListener; + /** A confirmed kill was sent and the shell has not reported its exit yet: nothing may attach to it now. */ + ending?: true; + /** Why the service ended it, when it was cleanup rather than a person; written with the spool's exit record. */ + endReason?: string; + /** At least one command ran (typed, queued) or a background task was requested. */ + used: boolean; + /** When it last lost its window. */ + detachedSince?: number; + /** A lifecycle policy was in effect when it was created; only such sessions are ever ended by a timer. */ + policyAware: boolean; + /** The one pending lifecycle check for this session, if any. */ + cleanupTimer?: NodeJS.Timeout; + /** A cleanup re-verification is in flight. */ + verifying?: boolean; } export {AlternateScreenTracker} from './TerminalModes.js'; @@ -89,6 +107,29 @@ export interface SessionServiceOptions { maxSessions?: number; /** Build identity reported in the welcome (informational). */ build?: string; + /** Initial lifecycle policy (tests); otherwise the first frontend that states one sets it, and until then nothing is automatic. */ + lifecycle?: LifecyclePolicy; + /** Child processes of a shell (its own PTY child; a shell switch during the read is detected); undefined when unreadable. */ + childCount?: (pid: number) => Promise; + /** How long a confirmed end waits for the shell to exit on SIGHUP before it sends SIGKILL to that same child. */ + killEscalationMs?: number; + /** Clock for lifecycle decisions (tests). */ + now?: () => number; +} + +/** A detached session holding work is looked at again this often while a policy could end it later; nothing else polls. */ +const LIFECYCLE_RECHECK_MS = 60 * 60_000; +const MAX_TIMER_MS = 24 * 60 * 60_000; + +/** `pgrep -P `: one bounded process-table read, only when a cleanup or End All Eligible is about to act. */ +export function shellChildCount(pid: number): Promise { + return new Promise(resolve => { + execFile('pgrep', ['-P', String(pid)], {timeout: 2000}, (error, stdout) => { + if (!error) { resolve(stdout.split('\n').filter(line => /^\d+$/u.test(line.trim())).length); return; } + // pgrep exits 1 when nothing matched; anything else (missing tool, timeout) is unknown, and unknown preserves. + resolve((error as {code?: unknown}).code === 1 ? 0 : undefined); + }); + }); } export const DEFAULT_MAX_SESSIONS = 16; @@ -129,12 +170,15 @@ export class SessionService { private readonly endedNotices = new EndedNotices(); private idleTimer: NodeJS.Timeout | undefined; private closed = false; + /** The person's lifecycle policy, as the most recent frontend stated it; undefined until one does. */ + private policy: LifecyclePolicy | undefined; readonly done: Promise; private resolveDone!: () => void; constructor(private readonly options: SessionServiceOptions) { this.socketPath = socketPathFor(options.runtimeDir); this.done = new Promise(resolve => { this.resolveDone = resolve; }); + this.policy = options.lifecycle; } get registry(): SessionRecord[] { @@ -166,7 +210,98 @@ export class SessionService { ...(request ? {inputSince: request.since, inputConfidence: request.confidence, inputMode: request.mode, ...(request.prompt ? {inputPrompt: request.prompt.slice(0, 120)} : {})} : {}), ...(features.has('queue') && session.queue.queue.size ? {queued: session.queue.queue.size, - ...(session.queue.state.paused ? {queuePaused: session.queue.state.paused.reason} : {})} : {})}; + ...(session.queue.state.paused ? {queuePaused: session.queue.state.paused.reason} : {})} : {}), + ...this.lifecycleInfo(session)}; + } + + /** What the lifecycle policy sees, for every surface: facts, the state they make, and what cleanup would do. */ + private lifecycleInfo(session: ManagedSession): Partial { + const facts = this.facts(session); + const now = this.now(); + const result = eligibility(facts, this.policy ?? {unusedMs: 0, idleExpiryMs: 0}, now); + const tasks = facts.tasks; + return {lifecycle: lifecycleState(facts), cleanup: result.reason, eligibility: result.kind, + ...(result.kind !== 'automatic' && result.at !== undefined && session.cleanupTimer ? {cleanupAt: result.at} : {}), + ...(session.used ? {used: 1} : {}), ...(session.detachedSince !== undefined ? {detachedSince: session.detachedSince} : {}), + ...(tasks ? {tasks} : {}), ...(facts.jobs ? {jobs: facts.jobs} : {}), + ...(session.record.protected ? {protected: 1} : {}), ...(session.ending ? {ending: 1} : {})}; + } + + private facts(session: ManagedSession, children?: number): LifecycleFacts { + const tasks = session.tasks.list().filter(task => task.state === 'running' || task.state === 'stopping' || task.leftovers).length; + const jobs = knowledgeJobCount(session.knowledge); + return {attached: Boolean(session.controller), ending: Boolean(session.ending), protected: Boolean(session.record.protected), + // A shell still in its startup (an rc file asking a question, or exec'ing tmux) or a fullscreen program is busy, never unused. + running: Boolean(session.running) || !session.shell.isReady || session.screen.ownsTerminal, + waiting: Boolean(session.running && session.input?.request), queued: session.queue.queue.size, + tasks, ...(jobs === undefined ? {} : {jobs}), ...(children === undefined ? {} : {children}), used: session.used, + ...(session.detachedSince === undefined ? {} : {detachedSince: session.detachedSince}), + lastActivity: Math.max(session.idleSince, session.evidence.snapshot().lastOutputAt ?? 0, session.detachedSince ?? 0), + policyAware: session.policyAware}; + } + + private now(): number { return (this.options.now ?? Date.now)(); } + + /** + * Arm this session's single lifecycle check, or none. Event driven: detach, prompt, a policy or protection change. + * Only a detached, policy-aware session whose policy could end it gets a timer, at the moment it could become eligible. + */ + private scheduleCleanup(session: ManagedSession): void { + if (session.cleanupTimer) { clearTimeout(session.cleanupTimer); session.cleanupTimer = undefined; } + const policy = this.policy; + if (!policy || this.closed || session.controller || session.ending || !session.policyAware || session.verifying || session.record.protected) return; + if (!policy.idleExpiryMs && (session.used || !policy.unusedMs)) return; + const result = eligibility(this.facts(session), policy, this.now()); + const due = result.kind === 'automatic' ? this.now() : result.at ?? this.now() + LIFECYCLE_RECHECK_MS; + session.cleanupTimer = setTimeout(() => { session.cleanupTimer = undefined; void this.verifyAndEnd(session); }, + Math.min(MAX_TIMER_MS, Math.max(0, due - this.now()))); + session.cleanupTimer.unref?.(); + } + + /** Re-verify everything right before ending, including child processes; anything uncertain keeps the session. */ + private async verifyAndEnd(session: ManagedSession): Promise { + if (this.sessions.get(session.record.id) !== session || session.verifying) return; + const policy = this.policy; + if (!policy || eligibility(this.facts(session), policy, this.now()).kind !== 'automatic') { this.scheduleCleanup(session); return; } + session.verifying = true; + const shell = session.shell; + let children: number | undefined; + try { children = await (this.options.childCount ?? shellChildCount)(session.record.pid); } + catch { children = undefined; } + finally { session.verifying = false; } + // Everything is read again after the wait: an attach, a command, a policy change, a shell switch or a kill may have happened. + if (this.sessions.get(session.record.id) !== session || !this.policy) return; + if (session.shell !== shell) { this.scheduleCleanup(session); return; } + const result = children === undefined ? undefined : eligibility(this.facts(session, children), this.policy, this.now()); + if (!result || result.kind !== 'automatic') { + // Something changed during the check (attach, a command, a policy): the ordinary schedule applies. + if (children === 0) { this.scheduleCleanup(session); return; } + // Child processes, or an unreadable process table: kept, looked at again only after a long interval. + if (!session.controller && !session.ending) { + session.cleanupTimer = setTimeout(() => { session.cleanupTimer = undefined; this.scheduleCleanup(session); }, LIFECYCLE_RECHECK_MS); + session.cleanupTimer.unref?.(); + } + return; + } + this.end(session, `Ended by NMSh session cleanup: ${result.reason}.`); + } + + /** + * End one session's shell: SIGHUP through node-pty, then, if the shell ignores it, SIGKILL to the same PTY child. + * node-pty reaps the child shortly before it reports the exit, so the escalation first confirms that the pid is + * still a child of this service (ShellSession.forceKill); a pid that is gone or belongs to anything else is left alone. + */ + private end(session: ManagedSession, reason?: string): void { + if (session.ending) return; + session.ending = true; + if (reason) session.endReason = reason; + if (session.cleanupTimer) { clearTimeout(session.cleanupTimer); session.cleanupTimer = undefined; } + const shell = session.shell; + const escalate = setTimeout(() => { if (this.sessions.get(session.record.id) === session && session.shell === shell) shell.forceKill(); }, + this.options.killEscalationMs ?? 3000); + escalate.unref?.(); + shell.once('exit', () => clearTimeout(escalate)); + shell.kill(); } async start(): Promise { @@ -202,6 +337,8 @@ export class SessionService { let greeted = false; let owned: ManagedSession | undefined; let features: ReadonlySet = new Set(); + // Whether this connection's own hello stated a lifecycle policy: only its sessions may ever be ended by a timer. + let statedPolicy = false; socket.on('data', chunk => { for (const result of decoder.push(chunk as unknown as string)) { @@ -220,6 +357,13 @@ export class SessionService { } greeted = true; features = parseClientFeatures(message.features); + // The person's policy as this frontend knows it; an older frontend says nothing and changes nothing. + const stated = decodePolicy(message.lifecycle); + statedPolicy = stated !== undefined; + if (stated && (stated.unusedMs !== this.policy?.unusedMs || stated.idleExpiryMs !== this.policy?.idleExpiryMs)) { + this.policy = stated; + for (const session of this.sessions.values()) this.scheduleCleanup(session); + } send({type: 'welcome', version: PROTOCOL_VERSION, service: SERVICE_NAME, startupSafety: 1, features: SERVICE_FEATURES.join(','), ...(this.options.build ? {build: this.options.build} : {})}); continue; @@ -234,7 +378,8 @@ export class SessionService { try { const backend = message.shell === undefined ? 'zsh' : message.shell; if (!isShellId(backend)) throw new Error(`unknown shell backend ${backend}`); - owned = this.create(message.cwd, message.env, message.columns, message.rows, send, backend); + owned = this.create(message.cwd, message.env, message.columns, message.rows, send, backend, + this.policy !== undefined && (statedPolicy || this.options.lifecycle !== undefined)); owned.controllerFeatures = features; send({type: 'created', sessionId: owned.record.id, pid: owned.record.pid, shell: owned.backend}); this.attachTasks(owned, send, features); @@ -247,6 +392,7 @@ export class SessionService { const session = this.sessions.get(message.sessionId); if (!session) { send({type: 'error', code: 'unknown', message: 'no live session with that id'}); break; } if (session.controller) { send({type: 'error', code: 'attached', message: 'session is attached to another frontend'}); break; } + if (session.ending) { send({type: 'error', code: 'unknown', message: 'that session is ending'}); break; } owned = session; this.bind(session, send, features); // Opening a session is focusing it: its notice is no longer news in any window. @@ -295,6 +441,7 @@ export class SessionService { if (!owned || !features.has('background-tasks')) send({type: 'error', code: 'state', message: 'no background tasks for this connection'}); else if (!change) send({type: 'error', code: 'task', message: 'not a background task request'}); else { + owned.used = true; const refused = owned.tasks.request(change); if (refused) send({type: 'error', code: 'task', message: refused}); } @@ -306,7 +453,7 @@ export class SessionService { else if (!change) send({type: 'error', code: 'queue', message: 'not a queue change'}); else if ((change.op === 'failure-policy' || change.expectedRevision !== undefined) && !features.has('queue-timeline')) send({type: 'error', code: 'queue', message: 'queue timeline review not negotiated'}); else if ((change.op === 'history-detail' || change.op === 'save-summary' || change.op === 'summaries-auto') && !features.has('queue-history')) send({type: 'error', code: 'queue', message: 'queue history review not negotiated'}); - else owned.queue.request(change); + else { if (change.op === 'add') owned.used = true; owned.queue.request(change); } break; } case 'input-look': { @@ -368,8 +515,36 @@ export class SessionService { if (!target) { send({type: 'error', code: 'unknown', message: 'no live session with that id'}); break; } // Never pull a shell out from under a frontend that is using it. if (target.controller) { send({type: 'error', code: 'attached', message: 'session is attached to another frontend'}); break; } + // An unknown condition is refused, never read as an unconditional kill. + if (message.when !== undefined && message.when !== 'eligible') { send({type: 'error', code: 'unsupported', message: `unsupported kill condition ${message.when.slice(0, 20)}`}); break; } + if (message.when === 'eligible' && !target.ending) { + // End All Eligible: the service re-verifies, with child processes, right before ending; work or doubt keeps it. + void (async () => { + const shell = target.shell; + let children: number | undefined; + try { children = await (this.options.childCount ?? shellChildCount)(target.record.pid); } catch { children = undefined; } + if (this.sessions.get(target.record.id) !== target) { send({type: 'error', code: 'unknown', message: 'no live session with that id'}); return; } + if (target.shell !== shell) { send({type: 'error', code: 'busy', message: 'its shell changed while it was being checked'}); return; } + if (target.controller) { send({type: 'error', code: 'attached', message: 'session is attached to another frontend'}); return; } + const result = eligibility(this.facts(target, children), this.policy ?? {unusedMs: 0, idleExpiryMs: 0}, this.now()); + if (children === undefined && !target.ending) { send({type: 'error', code: 'busy', message: 'its child processes could not be checked'}); return; } + if (result.kind === 'kept' && !target.ending) { send({type: 'error', code: 'busy', message: result.reason}); return; } + target.shell.once('exit', () => send({type: 'killed', sessionId: message.sessionId})); + this.end(target); + })(); + break; + } + // Between the kill and the shell's exit event no window may attach; a second kill just waits for the same exit. target.shell.once('exit', () => send({type: 'killed', sessionId: message.sessionId})); - target.shell.kill(); + this.end(target); + break; + } + case 'protect': { + const target = this.sessions.get(message.sessionId); + if (!target) { send({type: 'error', code: 'unknown', message: 'no live session with that id'}); break; } + if (message.on) target.record.protected = true; else delete target.record.protected; + this.scheduleCleanup(target); + send({type: 'sessions', sessions: [...this.sessions.values()].map(session => this.info(session, features)), ended: this.endedNotices.list(Date.now())}); break; } case 'terminate': owned?.shell.kill(); break; @@ -392,6 +567,8 @@ export class SessionService { session.controller = send; session.controllerFeatures = features; session.record.state = 'attached'; + session.detachedSince = undefined; + if (session.cleanupTimer) { clearTimeout(session.cleanupTimer); session.cleanupTimer = undefined; } session.queue.onOwnershipChanged(); } @@ -415,7 +592,9 @@ export class SessionService { session.controller = undefined; session.controllerFeatures = undefined; session.record.state = 'detached'; + session.detachedSince = Date.now(); session.queue.onOwnershipChanged(); + this.scheduleCleanup(session); } /** @@ -480,7 +659,7 @@ export class SessionService { } } - private create(cwd: string, env: Record, columns: number, rows: number, send: Send, backend: ShellId = 'zsh'): ManagedSession { + private create(cwd: string, env: Record, columns: number, rows: number, send: Send, backend: ShellId = 'zsh', policyAware = false): ManagedSession { // The shell gets the launching frontend's environment and cwd, never the // service's own startup state. The env is opaque: it is not stored or logged. const id = randomUUID(); @@ -492,7 +671,7 @@ export class SessionService { const session: ManagedSession = {record, shell, backend, env, size: {columns, rows}, controller: send, idleSince: Date.now(), screen: new AlternateScreenTracker(), resizes: 0, seq: 0, backlog: new StreamBacklog(spoolPathFor(this.options.runtimeDir, record.id), this.options.backlogLimits), evidence: new SessionEvidence(), notices: new SessionNoticeTracker(record.id), queue: new QueueDispatcher(shell), - tasks: undefined as unknown as SessionTasks}; + tasks: undefined as unknown as SessionTasks, used: false, policyAware}; // The session's own shell runs each task's command; the launch environment is used for the spawn only. session.tasks = new SessionTasks({shell: () => shellAdapter(session.backend).resolveExecutable(session.env), env: () => session.env}); session.queue.on('state', (state, event) => { @@ -528,6 +707,8 @@ export class SessionService { // The queue says whether this is the entry it just submitted: every window, live or later, learns it from here. const queued = session.queue.onExec(command, at, historyAllowed)?.text; session.running = {command, since: at, ...(queued === undefined ? {} : {queued})}; + session.used = true; + if (session.cleanupTimer) { clearTimeout(session.cleanupTimer); session.cleanupTimer = undefined; } session.evidence.onExec(); session.notices.onExec(command, at); emit({kind: 'exec', seq: ++session.seq, at, command, ...(historyAllowed === undefined ? {} : {historyAllowed}), ...(queued === undefined ? {} : {queued})}); @@ -545,6 +726,7 @@ export class SessionService { ...(marker.inputWaits ? {inputWaitMs: marker.inputWaitMs ?? 0, inputWaits: marker.inputWaits} : {})}); // After the prompt is on its way: the next queued command, if any, starts from this evidence only. session.queue.onPrompt(marker.exitCode, session.idleSince, marker); + if (!session.controller) this.scheduleCleanup(session); }); shell.on('exit', event => { session.queue.onShellExit(); @@ -552,12 +734,13 @@ export class SessionService { void session.tasks.shutdown(); this.sessions.delete(record.id); if (session.inputNoticeTimer) { clearTimeout(session.inputNoticeTimer); session.inputNoticeTimer = undefined; } + if (session.cleanupTimer) { clearTimeout(session.cleanupTimer); session.cleanupTimer = undefined; } // Ending with no window attached is news; with one attached, that window saw it. if (!session.controller) this.endedNotices.add(session.notices.ended(event.exitCode, Date.now(), record.cwd)); // Detached: keep what the journal lacks on disk for archiving. Attached: // the frontend journal is authoritative and the backlog goes. if (session.controller) session.backlog.dispose(); - else session.backlog.finish(event.exitCode, Date.now()); + else session.backlog.finish(event.exitCode, Date.now(), session.endReason); session.controller?.({type: 'exit', exitCode: event.exitCode, ...(event.signal ? {signal: event.signal} : {})}); session.controller = undefined; this.maybeShutdown(); @@ -613,7 +796,10 @@ export class SessionService { if (this.closed) return; this.closed = true; if (this.idleTimer) clearTimeout(this.idleTimer); - for (const session of this.sessions.values()) session.shell.kill(); + for (const session of this.sessions.values()) { + if (session.cleanupTimer) { clearTimeout(session.cleanupTimer); session.cleanupTimer = undefined; } + session.shell.kill(); + } await Promise.all([...this.sessions.values()].map(session => session.tasks.shutdown())); this.sessions.clear(); for (const socket of this.connections) socket.destroy(); diff --git a/src/session/SocketSessionClient.ts b/src/session/SocketSessionClient.ts index 964e61e2..2505c879 100644 --- a/src/session/SocketSessionClient.ts +++ b/src/session/SocketSessionClient.ts @@ -88,16 +88,22 @@ function request(socketPath: string, timeoutMs: number, first: ClientMessage }; const onError = (error: NodeJS.ErrnoException) => fail(`session service unreachable (${error.code ?? 'error'})`, error.code ?? 'error'); const onClose = () => fail('session service closed the connection', 'closed'); - socket.once('connect', () => send({type: 'hello', version: PROTOCOL_VERSION, client: 'nmsh', features: CLIENT_FEATURES.filter(feature => feature !== 'deferred-attach-resize' || deferAttachResize).join(',')})); + socket.once('connect', () => send({type: 'hello', version: PROTOCOL_VERSION, client: 'nmsh', features: CLIENT_FEATURES.filter(feature => feature !== 'deferred-attach-resize' || deferAttachResize).join(','), + ...(helloLifecycle ? {lifecycle: helloLifecycle} : {})})); socket.on('error', onError); socket.on('close', onClose); socket.on('data', onData); }); } -/** End a detached session; resolves once the shell has exited and its spool is final. */ -export async function killSession(socketPath: string, sessionId: string, timeoutMs = 5000): Promise { - const {socket} = await request(socketPath, timeoutMs, {type: 'kill', sessionId}, +let helloLifecycle: string | undefined; + +/** The person's session cleanup policy (lifecycle.ts encodePolicy), stated in every hello from now on. */ +export function setHelloLifecycle(policy: string | undefined): void { helloLifecycle = policy; } + +/** End a detached session; resolves once the shell has exited and its spool is final. With when='eligible' the service re-verifies eligibility and may refuse ('busy'). */ +export async function killSession(socketPath: string, sessionId: string, timeoutMs = 5000, when?: 'eligible'): Promise { + const {socket} = await request(socketPath, timeoutMs, {type: 'kill', sessionId, ...(when ? {when} : {})}, message => (message.type === 'killed' ? true : undefined)); socket.end(); } @@ -118,6 +124,14 @@ export async function listSessionsWithNotices(socketPath: string, timeoutMs = 30 return value; } +/** Protect or unprotect a live session (services that advertise 'lifecycle'); resolves with its updated record. */ +export async function protectSession(socketPath: string, sessionId: string, on: boolean, timeoutMs = 3000): Promise { + const {value, socket} = await request(socketPath, timeoutMs, {type: 'protect', sessionId, on: on ? 1 : 0}, + message => (message.type === 'sessions' ? message.sessions.find(session => session.id === sessionId) ?? null : undefined)); + socket.end(); + return value ?? undefined; +} + /** Rename a live session (display only); an empty name returns to its signature. */ export async function renameSession(socketPath: string, sessionId: string, name: string, timeoutMs = 3000): Promise { const {value, socket} = await request(socketPath, timeoutMs, {type: 'rename', sessionId, name}, diff --git a/src/session/StartupPicker.ts b/src/session/StartupPicker.ts index 3155a0a7..30363f75 100644 --- a/src/session/StartupPicker.ts +++ b/src/session/StartupPicker.ts @@ -1,6 +1,9 @@ import {KeyDecoder, type Key} from '../terminal/keys.js'; import type {SessionInfo} from './SessionProtocol.js'; import {formatAge, sessionText, tildePath} from './sessionList.js'; +import {liveStatusParts} from './liveStatus.js'; +import type {EndOutcome} from './liveSessions.js'; +import {SESSION_CLEANUP, SESSION_IDLE_EXPIRY, type SessionCleanup, type SessionIdleExpiry} from '../prompt/configuration.js'; import {GLYPHS, checkMark} from '../ui/glyphs.js'; // Launch-time restore screens, shown before any session is attached. Each has @@ -9,24 +12,38 @@ import {GLYPHS, checkMark} from '../ui/glyphs.js'; // transcript is archived, never discarded. const clipTo = (columns: number) => (raw: string) => { const text = sessionText(raw); return text.length > columns - 1 ? `${text.slice(0, Math.max(0, columns - 2))}…` : text; }; -const activity = (session: SessionInfo) => (session.running === undefined ? undefined : sessionText(session.running.replace(/\s+/gu, ' ')).slice(0, 60)) ?? 'zsh'; -const age = (session: SessionInfo, now: number) => formatAge(now - (session.runningSince ?? session.idleSince ?? session.createdAt)); +const shellName = (session: SessionInfo) => session.shell ?? 'zsh'; +/** What the session is doing, from the service's evidence only: the first one or two facts /sessions shows. */ +const activity = (session: SessionInfo, now: number) => sessionText(liveStatusParts(session, now).slice(0, session.running ? 2 : 1).join(' · ')); +/** The one-session screens have room to say it in words: idle at the prompt (for how long), or what runs. */ +const spelledActivity = (session: SessionInfo, now: number) => session.running ? activity(session, now) + : `idle at the prompt${session.idleSince ? ` · ${formatAge(now - session.idleSince)}` : ''}`; +/** Its own name, when the person gave one or the service assigned a signature. */ +const identity = (session: SessionInfo) => sessionText(session.name ?? session.signature ?? ''); +/** The service reports lifecycle facts (it advertised 'lifecycle'); older services say nothing and none of this shows. */ +const hasLifecycle = (sessions: readonly SessionInfo[]) => sessions.some(session => session.eligibility !== undefined); +const eligible = (session: SessionInfo) => session.eligibility === 'automatic' || session.eligibility === 'review'; +/** Cleanup status worth a row: protected, eligible, or counting down. Work is already in the activity column. */ +const cleanupTag = (session: SessionInfo) => session.protected ? 'protected' + // The activity column already says how long it has been idle. + : eligible(session) || session.cleanupAt !== undefined ? sessionText((session.cleanup ?? '').replace(/^idle [^;]+; /u, '')) : ''; // ── One detached session ───────────────────────────────────────────────────── /** resume: attach · not-now: fresh session, this launch only · always/never: also persist the startup setting. */ export type SinglePromptChoice = 'resume' | 'not-now' | 'always' | 'never' | 'kill'; -export function renderSinglePrompt(session: SessionInfo, columns: number, now: number): string[] { +export function renderSinglePrompt(session: SessionInfo, columns: number, now: number, message?: string): string[] { const clip = clipTo(columns); + const name = identity(session); return ['NMSh · 1 detached live session', '', - clip(` ${tildePath(session.cwd)}`), - clip(` ${session.running ? `running ${activity(session)}` : 'idle at the prompt'} · ${age(session, now)}`), - clip(` started ${formatAge(now - session.createdAt)} ago`), '', + clip(` ${name ? `${name} · ` : ''}${tildePath(session.cwd)} · ${shellName(session)}`), + clip(` ${spelledActivity(session, now)}`), + clip(` started ${formatAge(now - session.createdAt)} ago`), ...(cleanupTag(session) ? [clip(` ${cleanupTag(session)}`)] : []), '', ' R Resume', ' N Not now (start a new session; it keeps running)', ' X Kill session (ends the shell; transcript stays in /resume)', ' A Always resume', ' D Don\'t resume at startup (it keeps running)', '', - clip('Enter resume · Esc not now · sessions stay in /resume')]; + clip('Enter resume · Esc not now · sessions stay in /resume'), ...(message ? ['', clip(message)] : [])]; } export function singlePromptKey(key: Key): SinglePromptChoice | undefined { @@ -38,13 +55,29 @@ export function singlePromptKey(key: Key): SinglePromptChoice | undefined { // ── Kill confirmation (shared by both screens) ────────────────────────────── -export function renderKillConfirm(session: SessionInfo, columns: number, now: number): string[] { +export function renderKillConfirm(target: SessionInfo | readonly SessionInfo[], columns: number, now: number, mode: 'selected' | 'eligible' = 'selected'): string[] { const clip = clipTo(columns); - return ['Kill detached session?', '', - clip(` ${tildePath(session.cwd)}`), - clip(` ${session.running ? `running ${activity(session)}` : 'idle at the prompt'} · ${age(session, now)}`), '', - ' This ends the live shell.', ' Its transcript will remain available in /resume.', '', - clip('Enter/Y kill · Esc/N cancel')]; + const sessions = Array.isArray(target) ? target as readonly SessionInfo[] : [target as SessionInfo]; + const many = sessions.length > 1; + if (mode === 'eligible') { + const plural = sessions.length === 1 ? '' : 's'; + return [`End ${sessions.length} eligible session${plural}?`, '', + ...sessions.map(session => clip(` ${identity(session) ? `${identity(session)} · ` : ''}${tildePath(session.cwd)} · ${shellName(session)} · ${sessionText(session.cleanup ?? '')}`)), '', + ` Exactly ${sessions.length === 1 ? 'this session' : `these ${sessions.length}`}. Each is checked again just before it ends;`, + ' one that has started work, is protected or is attached elsewhere is kept.', + ` ${sessions.length === 1 ? 'Its transcript remains' : 'Their transcripts remain'} available in /resume.`, '', + clip(`Enter/Y end ${sessions.length === 1 ? 'it' : `all ${sessions.length}`} · Esc/N cancel`)]; + } + // Every target is named on its own line (the service caps live sessions, so the list is bounded): the confirmation + // is the exact set that will be ended, never "and N more", and two sessions in one directory stay distinguishable. + const named = (session: SessionInfo) => { const name = identity(session); return name ? `${name} · ` : ''; }; + return [many ? `Kill ${sessions.length} detached sessions?` : 'Kill detached session?', '', + ...sessions.flatMap(session => many + ? [clip(` ${named(session)}${tildePath(session.cwd)} · ${shellName(session)} · ${activity(session, now)}`)] + : [clip(` ${named(session)}${tildePath(session.cwd)} · ${shellName(session)}`), clip(` ${spelledActivity(session, now)}`)]), '', + many ? ` This ends ${sessions.length} live shells and whatever runs in them.` : ' This ends the live shell and whatever runs in it.', + ` ${many ? 'Their transcripts remain' : 'Its transcript will remain'} available in /resume. Sessions open in another window are not touched.`, '', + clip(`Enter/Y kill${many ? ` all ${sessions.length}` : ''} · Esc/N cancel`)]; } /** Enter/Y confirm, Esc/N/Ctrl+C cancel; anything else is ignored. */ @@ -62,51 +95,100 @@ export interface MultiPickerState { sessions: SessionInfo[]; cursor: number; selected: Set; - /** The session a kill confirmation is open for. */ - confirming?: string; + /** The sessions a kill confirmation is open for: the selected ones, or the highlighted one. */ + confirming?: string[]; + /** What the open confirmation is: End Selected (default) or End All Eligible. */ + confirmMode?: 'selected' | 'eligible'; + /** Sessions selected by Review Idle Sessions: ending them goes through the service's eligibility re-check. */ + reviewed?: Set; + /** Other live sessions worth counting in the header: attached in another window, and archived at this launch. */ + context?: PickerContext; /** One factual line about the last kill, shown under the list. */ message?: string; } /** Returned by multiPickerKey once a kill is confirmed; the caller performs it and re-enters the picker. */ -export interface KillRequest {kill: string} +/** mode 'eligible' re-checks every target; `recheck` names the ones (from Review Idle Sessions) that are re-checked in an End Selected. */ +export interface KillRequest {kill: string[]; mode?: 'eligible'; recheck?: string[]} +/** P: protect or unprotect the highlighted session (the service records it). */ +export interface ProtectRequest {protect: string; on: boolean} +/** C: open the cleanup settings. */ +export interface ConfigureRequest {configure: true} +export type PickerRequest = KillRequest | ProtectRequest | ConfigureRequest; + +export interface PickerContext {attachedElsewhere?: number; endedWhileAway?: number} + +export function createMultiPicker(sessions: SessionInfo[], context?: PickerContext): MultiPickerState { + return {sessions, cursor: 0, selected: new Set(), ...(context ? {context} : {})}; +} -export function createMultiPicker(sessions: SessionInfo[]): MultiPickerState { - return {sessions, cursor: 0, selected: new Set()}; +/** The header's counts, from the service's own lifecycle reading: working, waiting, idle; plus what is not listed. */ +function pickerHeader(state: MultiPickerState): string { + const count = state.sessions.length; + const parts = [`NMSh · ${count} detached live session${count === 1 ? '' : 's'}`]; + if (hasLifecycle(state.sessions)) { + const of = (...states: string[]) => state.sessions.filter(session => states.includes(session.lifecycle ?? '')).length; + const working = of('executing'); + const waiting = of('waiting'); + const idle = of('idle', 'unused'); + if (working) parts.push(`${working} working`); + if (waiting) parts.push(`${waiting} waiting`); + if (idle) parts.push(`${idle} idle`); + } + if (state.context?.attachedElsewhere) parts.push(`${state.context.attachedElsewhere} attached elsewhere`); + if (state.context?.endedWhileAway) parts.push(`${state.context.endedWhileAway} ended while away (in /resume)`); + return parts.join(' · '); } export function renderMultiPicker(state: MultiPickerState, columns: number, now: number): string[] { const clip = clipTo(columns); const width = Math.min(32, Math.max(...state.sessions.map(session => tildePath(session.cwd).length), 4)); - const commandWidth = Math.min(20, Math.max(...state.sessions.map(session => activity(session).length), 3)); - const confirming = state.sessions.find(session => session.id === state.confirming); - if (confirming) return renderKillConfirm(confirming, columns, now); - const rows = [`NMSh · ${state.sessions.length} detached live session${state.sessions.length === 1 ? '' : 's'}`, '']; + const named = Math.min(16, Math.max(0, ...state.sessions.map(session => identity(session).length))); + const confirming = state.confirming ? state.sessions.filter(session => state.confirming!.includes(session.id)) : []; + if (confirming.length) return renderKillConfirm(confirming, columns, now, state.confirmMode); + const rows = [clip(pickerHeader(state)), '']; state.sessions.forEach((session, index) => { const mark = checkMark(state.selected.has(session.id)); - rows.push(clip(`${index === state.cursor ? GLYPHS.selection : ' '} ${mark} ${tildePath(session.cwd).padEnd(width)} ${activity(session).padEnd(commandWidth)} ${age(session, now)}`)); + const name = named ? `${identity(session).slice(0, named).padEnd(named)} ` : ''; + const tag = cleanupTag(session); + rows.push(clip(`${index === state.cursor ? GLYPHS.selection : ' '} ${mark} ${name}${tildePath(session.cwd).padEnd(width)} ${shellName(session).padEnd(4)} ${activity(session, now)}${tag ? ` · ${tag}` : ''}`)); }); const count = state.selected.size; - rows.push('', clip(`↑↓ move · Space select · A all · X kill · Enter resume ${count === 0 ? 'none' : count} · Esc none`), - clip('Unselected sessions keep running and stay in /resume.'), ...(state.message ? [clip(state.message)] : [])); + // What Enter does is spelled out, and so is what X would end: the action under the cursor is never a guess. + const enter = count === 0 ? 'Enter resume this' : `Enter resume ${count} selected`; + const kill = count === 0 ? 'X kill this' : `X kill ${count} selected`; + const lifecycle = hasLifecycle(state.sessions) + ? [clip(`E end eligible (${state.sessions.filter(eligible).length}) · I review idle · P protect · C cleanup settings`)] : []; + rows.push('', clip(`${enter} · N new session · ↑↓ move · Space select · A all`), + clip(`${kill} · Esc new session · sessions you leave keep running (/resume)`), ...lifecycle, ...(state.message ? ['', clip(state.message)] : [])); return rows; } -/** Apply one key; returns the ids to resume (in list order) once the user is done, [] for none. */ -export function multiPickerKey(state: MultiPickerState, key: Key): string[] | KillRequest | undefined { +/** + * Apply one key; returns the ids to resume (in list order) once the user is done, [] for a new session. Enter resumes + * the selected sessions, or the highlighted one when none is selected; N and Esc start a new session and leave every + * listed one running. X asks to kill the selected sessions, or the highlighted one. + */ +export function multiPickerKey(state: MultiPickerState, key: Key): string[] | PickerRequest | undefined { const count = state.sessions.length; if (state.confirming) { const decision = killConfirmKey(key); - const id = state.confirming; - if (decision) state.confirming = undefined; - return decision === 'confirm' ? {kill: id} : undefined; + const ids = state.confirming; + const mode = state.confirmMode; + if (decision) { state.confirming = undefined; state.confirmMode = undefined; } + const recheck = mode === 'eligible' ? [] : ids.filter(id => state.reviewed?.has(id)); + return decision === 'confirm' ? {kill: [...ids], ...(mode === 'eligible' ? {mode} : {}), ...(recheck.length ? {recheck} : {})} : undefined; } state.message = undefined; - if (key.kind === 'text' && key.value.toLowerCase() === 'x') state.confirming = state.sessions[state.cursor]?.id; + const highlighted = state.sessions[state.cursor]; + const letter = key.kind === 'text' ? key.value.toLowerCase() : ''; + if (hasLifecycle(state.sessions) && ['e', 'i', 'p', 'c'].includes(letter)) return lifecycleKey(state, letter); + const chosen = () => state.selected.size ? state.sessions.filter(session => state.selected.has(session.id)).map(session => session.id) : highlighted ? [highlighted.id] : []; + if (key.kind === 'text' && key.value.toLowerCase() === 'x') { const ids = chosen(); if (ids.length) state.confirming = ids; } else if (key.kind === 'up') state.cursor = (state.cursor - 1 + count) % count; else if (key.kind === 'down') state.cursor = (state.cursor + 1) % count; - else if (key.kind === 'escape' || key.kind === 'interrupt') return []; - else if (key.kind === 'enter') return state.sessions.filter(session => state.selected.has(session.id)).map(session => session.id); + else if (key.kind === 'escape' || key.kind === 'interrupt' || (key.kind === 'text' && key.value.toLowerCase() === 'n')) return []; + else if (key.kind === 'enter') return chosen(); else if (key.kind === 'text' && key.value === ' ') { const id = state.sessions[state.cursor]!.id; if (!state.selected.delete(id)) state.selected.add(id); @@ -118,15 +200,40 @@ export function multiPickerKey(state: MultiPickerState, key: Key): string[] | Ki return undefined; } +/** E end eligible, I review idle, P protect, C cleanup settings: only when the service reports lifecycle facts. */ +function lifecycleKey(state: MultiPickerState, letter: string): PickerRequest | undefined { + const highlighted = state.sessions[state.cursor]; + if (letter === 'e') { + // Exactly the sessions the service called eligible, listed in the confirmation; the service checks each again. + const ids = state.sessions.filter(eligible).map(session => session.id); + if (ids.length) { state.confirming = ids; state.confirmMode = 'eligible'; } + else state.message = 'No session is eligible for cleanup: each is attached, protected, working or recently used.'; + return undefined; + } + if (letter === 'i') { + // Review: select the long-idle ones (Space keeps one); X then asks before ending exactly those. + const idle = state.sessions.filter(session => session.lifecycle === 'idle' && eligible(session)); + if (!idle.length) { state.message = 'No session has been idle for over a day.'; return undefined; } + state.selected = new Set(idle.map(session => session.id)); + // Suggested from the listing, which cannot see child processes: the service checks each again before ending it. + state.reviewed = new Set(state.selected); + state.cursor = state.sessions.indexOf(idle[0]!); + state.message = `Selected ${idle.length} session${idle.length === 1 ? '' : 's'} idle for over a day: Space keeps one, X ends the selected after a confirmation.`; + return undefined; + } + if (letter === 'p') return highlighted ? {protect: highlighted.id, on: !highlighted.protected} : undefined; + return {configure: true}; +} + /** Drop a session (killed or already gone): selection and cursor stay consistent and in bounds. */ export function removeFromMultiPicker(state: MultiPickerState, id: string): void { state.sessions = state.sessions.filter(session => session.id !== id); state.selected.delete(id); - if (state.confirming === id) state.confirming = undefined; + if (state.confirming) { state.confirming = state.confirming.filter(item => item !== id); if (!state.confirming.length) state.confirming = undefined; } state.cursor = Math.max(0, Math.min(state.cursor, state.sessions.length - 1)); } -export type KillResult = 'killed' | 'gone' | 'attached'; +export type KillResult = EndOutcome; /** * The multi-session screen with in-place kill. `run` shows one screen until @@ -136,26 +243,91 @@ export type KillResult = 'killed' | 'gone' | 'attached'; */ export async function pickWithKill(sessions: SessionInfo[], io: { run: (render: (columns: number) => string[], onKey: (key: Key) => T | undefined) => Promise; - kill: (session: SessionInfo) => Promise; + kill: (session: SessionInfo, mode?: 'eligible') => Promise; + /** Record protection with the service; resolves with the session's updated record. */ + protect?: (session: SessionInfo, on: boolean) => Promise; + /** The cleanup settings screen; resolves with a line to show, after the service has the new policy. */ + configure?: () => Promise; + /** The service's current list, to redraw from after a settings change. */ + refresh?: () => Promise; + context?: PickerContext; now?: () => number; }): Promise { - const state = createMultiPicker(sessions); + const state = createMultiPicker(sessions, io.context); const now = io.now ?? Date.now; for (;;) { - const result = await io.run(columns => renderMultiPicker(state, columns, now()), key => multiPickerKey(state, key)); + const result = await io.run(columns => renderMultiPicker(state, columns, now()), key => multiPickerKey(state, key)); if (Array.isArray(result)) return result; - const target = state.sessions.find(session => session.id === result.kill); - if (!target) continue; - let outcome: KillResult; - try { outcome = await io.kill(target); } - catch (error) { state.message = `Could not end that session: ${error instanceof Error ? error.message : String(error)}`; continue; } - removeFromMultiPicker(state, target.id); - state.message = outcome === 'killed' ? 'Session ended; its transcript is in /resume.' - : outcome === 'gone' ? 'That session had already ended; it is in /resume.' : 'That session is attached in another window and was not touched.'; + if ('protect' in result) { + const target = state.sessions.find(session => session.id === result.protect); + if (!target || !io.protect) continue; + try { + const updated = await io.protect(target, result.on); + if (!updated) { state.message = 'That session has ended.'; removeFromMultiPicker(state, target.id); } + else { + state.sessions = state.sessions.map(session => session.id === updated.id ? updated : session); + state.message = updated.protected ? 'Protected: cleanup and End All Eligible never end it.' : 'No longer protected: cleanup applies to it again.'; + } + } catch (error) { state.message = `Could not change protection: ${error instanceof Error ? error.message : String(error)}`; } + if (state.sessions.length === 0) return []; + continue; + } + if ('configure' in result) { + if (!io.configure) continue; + try { state.message = await io.configure(); } catch (error) { state.message = `Could not save cleanup settings: ${error instanceof Error ? error.message : String(error)}`; } + if (io.refresh) { + try { + const fresh = new Map((await io.refresh()).map(session => [session.id, session])); + // Same rows, newest facts; a session that ended or was attached elsewhere meanwhile drops out. + for (const session of state.sessions) if (fresh.get(session.id)?.state !== 'detached') removeFromMultiPicker(state, session.id); + state.sessions = state.sessions.map(session => fresh.get(session.id) ?? session); + } catch { /* keep the rows as they were */ } + } + if (state.sessions.length === 0) return []; + continue; + } + const targets = state.sessions.filter(session => result.kill.includes(session.id)); + if (!targets.length) continue; + // One at a time, each re-checked by the service: a session attached elsewhere meanwhile is left alone. + const tally: KillTally = {killed: 0, 'killed-unarchived': 0, gone: 0, attached: 0, kept: 0, failed: []}; + for (const target of targets) { + let outcome: KillResult; + try { outcome = await io.kill(target, result.mode ?? (result.recheck?.includes(target.id) ? 'eligible' : undefined)); } + catch (error) { tally.failed.push(error instanceof Error ? error.message : String(error)); continue; } + tally[outcome] += 1; + if (outcome !== 'attached' && outcome !== 'kept') removeFromMultiPicker(state, target.id); + else state.selected.delete(target.id); + } + state.message = killSummary(tally); if (state.sessions.length === 0) return []; } } +type KillTally = Record & {failed: string[]}; + +/** One factual line about what a kill did, session by session; what failed comes first, so a narrow screen never clips it. */ +function killSummary(tally: KillTally): string { + const sessions = (count: number) => `${count} session${count === 1 ? '' : 's'}`; + const unarchived = tally['killed-unarchived']; + const kinds = [tally.failed.length, tally.attached, tally.kept, tally.killed, unarchived, tally.gone].filter(Boolean).length; + if (kinds === 1) { + const one = (count: number) => count === 1; + if (one(tally.failed.length)) return `Could not end that session: ${tally.failed[0]}`; + if (one(tally.attached)) return 'That session is attached in another window and was not touched.'; + if (one(tally.kept)) return 'That session was checked again and kept: it has work, is protected, or is not eligible yet.'; + if (one(tally.killed)) return 'Session ended; its transcript is in /resume.'; + if (one(tally.gone)) return 'That session had already ended; it is in /resume.'; + } + const parts: string[] = []; + if (tally.failed.length) parts.push(`${sessions(tally.failed.length)} could not be ended (${tally.failed[0]})`); + if (tally.attached) parts.push(`${sessions(tally.attached)} attached in another window ${tally.attached === 1 ? 'was' : 'were'} not touched`); + if (tally.kept) parts.push(`${sessions(tally.kept)} checked again and kept (work, protection or not eligible yet)`); + if (tally.killed) parts.push(`${sessions(tally.killed)} ended`); + if (unarchived) parts.push(`${sessions(unarchived)} ended but ${unarchived === 1 ? 'its transcript was' : 'their transcripts were'} not archived yet (kept; the next launch archives ${unarchived === 1 ? 'it' : 'them'})`); + if (tally.gone) parts.push(`${sessions(tally.gone)} had already ended`); + return `${parts.join('; ')}${tally.killed || tally.gone ? '; transcripts stay in /resume.' : '.'}`; +} + /** The one-session prompt with in-place kill; confirmation cancels back to the prompt. */ export async function askWithKill(session: SessionInfo, io: { run: (render: (columns: number) => string[], onKey: (key: Key) => T | undefined) => Promise; @@ -163,16 +335,59 @@ export async function askWithKill(session: SessionInfo, io: { now?: () => number; }): Promise { const now = io.now ?? Date.now; + let message: string | undefined; for (;;) { - const choice = await io.run(columns => renderSinglePrompt(session, columns, now()), singlePromptKey); + const choice = await io.run(columns => renderSinglePrompt(session, columns, now(), message), singlePromptKey); + message = undefined; if (choice !== 'kill') return choice; const decision = await io.run<'confirm' | 'cancel'>(columns => renderKillConfirm(session, columns, now()), killConfirmKey); if (decision === 'cancel') continue; - try { await io.kill(session); } catch { continue; } + // Only an ended session lets startup go on fresh; anything else is said on the prompt, never treated as killed. + let outcome: KillResult; + try { outcome = await io.kill(session); } + catch (error) { message = `Could not end that session: ${error instanceof Error ? error.message : String(error)}`; continue; } + if (outcome === 'attached') { message = 'That session is attached in another window and was not touched.'; continue; } + if (outcome === 'kept') { message = 'That session was checked again and kept.'; continue; } return 'kill'; } } +// ── Cleanup settings (C in the picker) ───────────────────────────────────── + +export interface CleanupSettings {sessionCleanup: SessionCleanup; sessionIdleExpiry: SessionIdleExpiry} +const CLEANUP_LABELS: Record = {off: 'Off', '10m': '10 minutes', '30m': '30 minutes', '1h': '1 hour'}; +const EXPIRY_LABELS: Record = {off: 'Off', '1d': 'After 1 day', '3d': 'After 3 days', '7d': 'After 7 days'}; + +export interface CleanupSettingsState {draft: CleanupSettings; cursor: 0 | 1} + +export function renderCleanupSettings(state: CleanupSettingsState, columns: number): string[] { + const clip = clipTo(columns); + const row = (index: 0 | 1, label: string, value: string) => clip(`${state.cursor === index ? GLYPHS.selection : ' '} ${label.padEnd(26)} ‹ ${value} ›`); + return ['Session cleanup', '', + row(0, 'Clean up unused sessions', CLEANUP_LABELS[state.draft.sessionCleanup]), + row(1, 'End idle sessions', EXPIRY_LABELS[state.draft.sessionIdleExpiry]), '', + clip(' Unused: a detached session that never ran a command ends after this long detached.'), + clip(' Idle: a used detached session ends after this long with no activity (off by default).'), + clip(' Never ended: attached, protected, running, waiting for input, queued, background tasks,'), + clip(' shell jobs or child processes. Each is checked again just before; transcripts are kept.'), '', + clip('↑↓ select · ←→ change · Enter save · Esc back')]; +} + +/** ↑↓ select, ←→ change, Enter save (returns the draft), Esc back (returns null). */ +export function cleanupSettingsKey(state: CleanupSettingsState, key: Key): CleanupSettings | null | undefined { + if (key.kind === 'up' || key.kind === 'down') { state.cursor = state.cursor === 0 ? 1 : 0; return undefined; } + if (key.kind === 'left' || key.kind === 'right') { + const step = key.kind === 'right' ? 1 : -1; + const cycle = (values: readonly T[], value: T) => values[(values.indexOf(value) + step + values.length) % values.length]!; + if (state.cursor === 0) state.draft = {...state.draft, sessionCleanup: cycle(SESSION_CLEANUP, state.draft.sessionCleanup)}; + else state.draft = {...state.draft, sessionIdleExpiry: cycle(SESSION_IDLE_EXPIRY, state.draft.sessionIdleExpiry)}; + return undefined; + } + if (key.kind === 'enter') return state.draft; + if (key.kind === 'escape' || key.kind === 'interrupt') return null; + return undefined; +} + // ── Terminal runner ───────────────────────────────────────────────────────── /** Run a full-screen prompt on the real terminal until `onKey` returns a result. */ @@ -194,6 +409,7 @@ export function runStartupScreen(render: (columns: number) => string[], onKey const result = onKey(key); if (result !== undefined) { process.stdin.off('data', onData); + process.stdout.off('resize', onResize); process.stdin.setRawMode(wasRaw); process.stdin.pause(); process.stdout.write('\u001b[?25h\u001b[?1049l'); @@ -203,6 +419,9 @@ export function runStartupScreen(render: (columns: number) => string[], onKey } draw(); }; + // A resize redraws at the new width (narrow terminals keep every line clipped, never wrapped). + const onResize = () => draw(); + process.stdout.on('resize', onResize); process.stdin.on('data', onData); process.stdin.resume(); draw(); diff --git a/src/session/StreamBacklog.ts b/src/session/StreamBacklog.ts index d8874db2..eb87cbe3 100644 --- a/src/session/StreamBacklog.ts +++ b/src/session/StreamBacklog.ts @@ -12,7 +12,7 @@ export type BacklogEvent = type SpoolRecord = BacklogEvent | {kind: 'ack'; seq: number; journalId: string} | {kind: 'truncated'; bytes: number} - | {kind: 'exit'; exitCode: number; at: number}; + | {kind: 'exit'; exitCode: number; at: number; reason?: string}; export interface BacklogLimits { /** Unacknowledged bytes kept in memory before spilling to the spool file. */ @@ -44,7 +44,7 @@ function validEvent(value: unknown): value is SpoolRecord { && (record.knowledge === undefined || typeof record.knowledge === 'string' && Buffer.byteLength(record.knowledge) <= MAX_SHELL_KNOWLEDGE_BYTES); case 'ack': return int('seq') && typeof record.journalId === 'string'; case 'truncated': return int('bytes'); - case 'exit': return int('exitCode') && int('at'); + case 'exit': return int('exitCode') && int('at') && (record.reason === undefined || typeof record.reason === 'string' && record.reason.length <= 300); default: return false; } } @@ -54,7 +54,7 @@ export interface SpoolContents { ackedSeq: number; journalId?: string; truncatedBytes: number; - exit?: {exitCode: number; at: number}; + exit?: {exitCode: number; at: number; reason?: string}; } /** @@ -74,7 +74,7 @@ export function readSpool(path: string): SpoolContents { if (record.kind === 'ack') { if (record.seq >= contents.ackedSeq) { contents.ackedSeq = record.seq; contents.journalId = record.journalId; } } else if (record.kind === 'truncated') contents.truncatedBytes += record.bytes; - else if (record.kind === 'exit') contents.exit = {exitCode: record.exitCode, at: record.at}; + else if (record.kind === 'exit') contents.exit = {exitCode: record.exitCode, at: record.at, ...(record.reason ? {reason: record.reason} : {})}; else contents.events.push(record); } contents.events = contents.events.filter(event => event.seq > contents.ackedSeq); @@ -136,9 +136,9 @@ export class StreamBacklog { * later. Always written: the exit record is how recovery tells an ordinary * exit from a service that died. */ - finish(exitCode: number, at: number): void { + finish(exitCode: number, at: number, reason?: string): void { this.spill(); - this.write([{kind: 'exit', exitCode, at}]); + this.write([{kind: 'exit', exitCode, at, ...(reason ? {reason: reason.slice(0, 300)} : {})}]); } dispose(): void { diff --git a/src/session/lifecycle.ts b/src/session/lifecycle.ts new file mode 100644 index 00000000..868d26c1 --- /dev/null +++ b/src/session/lifecycle.ts @@ -0,0 +1,139 @@ +import {formatAge} from './sessionList.js'; +import type {SessionCleanup, SessionIdleExpiry} from '../prompt/configuration.js'; + +/** + * Session lifecycle policy: what a live session is doing, from facts the service observed, and whether NMSh may end + * it without being asked. One pure function decides it for the service (automatic cleanup, End All Eligible) and for + * every surface that shows it, so the picker and the service can never disagree. + * + * The rules, smallest first: + * - Anything attached to a window, ending, protected, or holding work (a running command, an input wait, queued + * commands, background tasks, shell jobs, child processes) is never eligible. Idle output alone proves nothing. + * - A detached session that never ran a command is eligible after the unused grace period (default 10 minutes). + * - A detached idle session is only suggested for review after a day; idle expiry ends it automatically only when + * the person turned that on. + * - A session created before any lifecycle policy reached the service keeps the behaviour it was created with: + * nothing automatic. + * - When a fact is unknown (child processes could not be read), the session is preserved. + */ + +export interface LifecyclePolicy { + /** End a detached, never-used session this long after it was detached; 0 is off. */ + unusedMs: number; + /** End a detached idle session this long after its last activity; 0 is off (the default). */ + idleExpiryMs: number; +} + +/** Long-idle detached sessions are suggested for review (never ended) after this. */ +export const REVIEW_IDLE_MS = 24 * 60 * 60_000; +export const POLICY_OFF: LifecyclePolicy = {unusedMs: 0, idleExpiryMs: 0}; + +/** The bounded values a policy may hold; anything else is clamped to the nearest edge or read as off. */ +export const UNUSED_BOUNDS = {min: 60_000, max: 24 * 60 * 60_000}; +export const IDLE_EXPIRY_BOUNDS = {min: 60 * 60_000, max: 30 * 24 * 60 * 60_000}; + +const bounded = (value: number, bounds: {min: number; max: number}) => !Number.isFinite(value) || value <= 0 ? 0 : Math.min(bounds.max, Math.max(bounds.min, Math.round(value))); + +/** The policy as it travels in a hello: `unused=;idle=`, both bounded. */ +export function encodePolicy(policy: LifecyclePolicy): string { + return `unused=${Math.round(bounded(policy.unusedMs, UNUSED_BOUNDS) / 1000)};idle=${Math.round(bounded(policy.idleExpiryMs, IDLE_EXPIRY_BOUNDS) / 1000)}`; +} + +/** Parse a hello's policy; undefined (keep what the service had) for anything malformed. */ +export function decodePolicy(text: string | undefined): LifecyclePolicy | undefined { + const match = /^unused=(\d{1,7});idle=(\d{1,8})$/u.exec(text ?? ''); + if (!match) return undefined; + return {unusedMs: bounded(Number(match[1]) * 1000, UNUSED_BOUNDS), idleExpiryMs: bounded(Number(match[2]) * 1000, IDLE_EXPIRY_BOUNDS)}; +} + +/** What the service knows about one session at one moment. */ +export interface LifecycleFacts { + attached: boolean; + /** A confirmed kill was sent and the shell has not exited yet. */ + ending: boolean; + protected: boolean; + /** A foreground command is running (preexec seen, no prompt since). */ + running: boolean; + /** The running command credibly waits for input. */ + waiting: boolean; + queued: number; + /** Background tasks still running (or whose processes still run). */ + tasks: number; + /** Background or stopped shell jobs, as the shell last reported them; undefined when it did not say. */ + jobs?: number; + /** Child processes of the idle shell; undefined when not checked or unreadable. */ + children?: number; + /** At least one command ran in this session (any exec, queued or typed). */ + used: boolean; + /** When the session last lost its window; undefined while attached. */ + detachedSince?: number; + /** Latest of: last prompt, last output, detach. */ + lastActivity: number; + /** A lifecycle policy was in effect when the session was created. */ + policyAware: boolean; +} + +export type LifecycleState = 'attached' | 'ending' | 'executing' | 'waiting' | 'unused' | 'idle'; + +export type Eligibility = + /** NMSh may end it without asking (unused grace passed, or idle expiry on and passed); re-verified before ending. */ + | {kind: 'automatic'; why: 'unused' | 'idle-expired'; reason: string} + /** Offered in End All Eligible and Review Idle Sessions, always behind a confirmation that lists it; `at` as for kept. */ + | {kind: 'review'; reason: string; at?: number} + /** Kept. `at` is when it would next become eligible, if nothing changes. */ + | {kind: 'kept'; reason: string; at?: number}; + +export function lifecycleState(facts: LifecycleFacts): LifecycleState { + if (facts.attached) return 'attached'; + if (facts.ending) return 'ending'; + if (facts.waiting) return 'waiting'; + if (facts.running || facts.queued > 0 || facts.tasks > 0 || (facts.jobs ?? 0) > 0) return 'executing'; + return facts.used ? 'idle' : 'unused'; +} + +/** Work that keeps a session alive regardless of any timer; undefined when none is known. */ +export function heldWork(facts: LifecycleFacts): string | undefined { + if (facts.waiting) return 'waiting for input'; + if (facts.running) return 'a command is running'; + if (facts.queued > 0) return `${facts.queued} queued command${facts.queued === 1 ? '' : 's'}`; + if (facts.tasks > 0) return `${facts.tasks} background task${facts.tasks === 1 ? '' : 's'}`; + if ((facts.jobs ?? 0) > 0) return `${facts.jobs} shell job${facts.jobs === 1 ? '' : 's'}`; + if ((facts.children ?? 0) > 0) return `${facts.children} child process${facts.children === 1 ? '' : 'es'}`; + return undefined; +} + +export function eligibility(facts: LifecycleFacts, policy: LifecyclePolicy, now: number): Eligibility { + if (facts.attached) return {kind: 'kept', reason: 'attached in a window'}; + if (facts.ending) return {kind: 'kept', reason: 'ending'}; + if (facts.protected) return {kind: 'kept', reason: 'protected'}; + const work = heldWork(facts); + if (work) return {kind: 'kept', reason: work}; + const detachedFor = now - (facts.detachedSince ?? now); + const idleFor = now - facts.lastActivity; + // Idle expiry, when the person turned it on, applies to every detached session without work, used or not. + const expiry = facts.policyAware && policy.idleExpiryMs > 0 ? facts.lastActivity + policy.idleExpiryMs : undefined; + if (expiry !== undefined && now >= expiry) return {kind: 'automatic', why: 'idle-expired', reason: `idle ${formatAge(idleFor)}`}; + const until = (at: number | undefined) => (at === undefined ? {} : {at}); + if (!facts.used) { + if (facts.policyAware && policy.unusedMs > 0) { + if (detachedFor >= policy.unusedMs) return {kind: 'automatic', why: 'unused', reason: `never used; detached ${formatAge(detachedFor)}`}; + return {kind: 'kept', reason: `never used; cleanup in ${formatAge(policy.unusedMs - detachedFor)}`, at: (facts.detachedSince ?? now) + policy.unusedMs}; + } + // Without automatic cleanup an unused session is still worth offering once it has sat for the grace period. + if (detachedFor >= 10 * 60_000) return {kind: 'review', reason: `never used; detached ${formatAge(detachedFor)}`, ...until(expiry)}; + return {kind: 'kept', reason: 'never used', ...until(expiry)}; + } + if (idleFor >= REVIEW_IDLE_MS) return {kind: 'review', reason: `idle ${formatAge(idleFor)}; cleanup suggested`, ...until(expiry)}; + return {kind: 'kept', reason: `idle ${formatAge(idleFor)}`, at: expiry ?? facts.lastActivity + REVIEW_IDLE_MS}; +} + +/** Eligible for End All Eligible: automatic or review, never kept. */ +export const isEligible = (result: Eligibility) => result.kind !== 'kept'; + +const CLEANUP_MS: Record = {off: 0, '10m': 10 * 60_000, '30m': 30 * 60_000, '1h': 60 * 60_000}; +const IDLE_EXPIRY_MS: Record = {off: 0, '1d': 24 * 60 * 60_000, '3d': 3 * 24 * 60 * 60_000, '7d': 7 * 24 * 60 * 60_000}; + +/** The two Session Lifecycle settings as a policy; unknown values are off. */ +export function policyFromSettings(settings: {sessionCleanup: SessionCleanup; sessionIdleExpiry: SessionIdleExpiry}): LifecyclePolicy { + return {unusedMs: CLEANUP_MS[settings.sessionCleanup] ?? 0, idleExpiryMs: IDLE_EXPIRY_MS[settings.sessionIdleExpiry] ?? 0}; +} diff --git a/src/session/liveSessions.ts b/src/session/liveSessions.ts index 9b8b2507..6f6adf7a 100644 --- a/src/session/liveSessions.ts +++ b/src/session/liveSessions.ts @@ -45,11 +45,57 @@ export interface KillOptions { export async function killAndArchive(session: SessionInfo, options: KillOptions = {}): Promise { const runtimeDir = options.runtimeDir ?? defaultRuntimeDir(options.env ?? process.env); await killSession(socketPathFor(runtimeDir), session.id); - // Same ownership boundary as launch recovery, which may be finalizing this - // session concurrently now that the service no longer lists it. + return archiveKilled(session, {...options, runtimeDir}); +} + +/** + * Archive a session whose shell this process just ended. Same ownership boundary as launch recovery, which may be + * finalizing it concurrently now that the service no longer lists it. On failure the spool is kept for a later launch. + */ +export async function archiveKilled(session: SessionInfo, options: KillOptions = {}): Promise { + const runtimeDir = options.runtimeDir ?? defaultRuntimeDir(options.env ?? process.env); const outcome = await finalizeLiveSession({store: options.store ?? new TranscriptStore(), runtimeDir, sessionId: session.id, ...(session.journalId ? {journalId: session.journalId} : {}), cwd: session.cwd, waitMs: 10_000, note: () => `Session killed from ${options.origin ?? '/resume'}; its shell has ended.`}); if (outcome.kind === 'claimed-elsewhere') throw new Error('Another NMSh window is archiving this session.'); return outcome.session; } + +/** + * What ending one listed detached session did, from the service's own answers only: + * - killed: this call ended its shell and the transcript was archived; + * - killed-unarchived: this call ended its shell; the transcript stays in the spool and a later launch archives it; + * - gone: the service no longer had it before this call could end it; + * - attached: it is attached in a window (or became attached), and was not touched; + * - kept: End All Eligible only: the service re-verified it and found work, protection or doubt, so it was not ended. + */ +export type EndOutcome = 'killed' | 'killed-unarchived' | 'gone' | 'attached' | 'kept'; + +export interface EndDetachedDeps { + /** The service's current list. Throws when the service cannot be asked: that is never "already ended". */ + list: () => Promise; + /** Ask the service to end the session by id; it refuses ('attached') if a window holds it at that moment. */ + kill: (session: SessionInfo) => Promise; + archive: (session: SessionInfo) => Promise; +} + +/** + * End one session the person confirmed. Targets are session ids the service issued (never a PID); the service's list + * is read again first, and the service itself re-checks attachment when it receives the kill. Anything this cannot + * prove is reported as it is: a failure throws, and only a shell this call ended counts as killed. + */ +export async function endDetachedSession(session: SessionInfo, deps: EndDetachedDeps): Promise { + const current = (await deps.list()).find(item => item.id === session.id); + if (!current) return 'gone'; + if (current.state !== 'detached') return 'attached'; + try { await deps.kill(current); } + catch (error) { + const code = (error as {code?: string}).code; + if (code === 'attached') return 'attached'; + if (code === 'unknown') return 'gone'; + if (code === 'busy') return 'kept'; + throw error; + } + try { await deps.archive(current); return 'killed'; } + catch { return 'killed-unarchived'; } +} diff --git a/src/session/recovery.ts b/src/session/recovery.ts index f07c6dc9..aadbbd38 100644 --- a/src/session/recovery.ts +++ b/src/session/recovery.ts @@ -163,7 +163,9 @@ export async function recoverEndedSessions(runtimeDir: string, store: Transcript if (live.has(sessionId) || !/^[\w-]+$/u.test(sessionId)) continue; try { const outcome = await finalizeLiveSession({store, runtimeDir, sessionId, journalId: linked.get(sessionId), cwd: '/', - note: spool => spool.exit + note: spool => spool.exit?.reason + ? `${spool.exit.reason} No NMSh window was attached; the transcript is kept.` + : spool.exit ? `The shell exited while no NMSh window was attached (exit code ${spool.exit.exitCode}).` : SERVICE_STOPPED_NOTE}); if (outcome.kind === 'archived') archived.push(outcome.session.id); diff --git a/src/setup/SetupCat.ts b/src/setup/SetupCat.ts index 895aefd2..1258e9c7 100644 --- a/src/setup/SetupCat.ts +++ b/src/setup/SetupCat.ts @@ -397,6 +397,9 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ 'Session notices are short events about another session (a finished command, a failure, a request for attention); /sessions keeps the state.'], rows: [ {row: configRow('liveSessionStartup')}, {row: configRow('liveSessionMultiple')}, + {row: configRow('sessionCleanup'), note: draft => draft.sessionCleanup === 'off' ? 'Off: detached sessions are never ended by a timer; long-idle ones are still suggested for review' + : 'A detached session that never ran a command ends after this long. Attached, protected or busy sessions never do; the transcript is kept'}, + {row: configRow('sessionIdleExpiry')}, {row: configRow('commandQueue'), note: draft => draft.commandQueue ? 'On: while a command runs, Enter queues the next one; a program waiting for input still gets your answer' : 'Off: Enter while a command runs goes to the program, as in a plain terminal'}, {row: configRow('queueFailurePolicy'), note: () => 'Applies to new execution chains; existing reviewed work keeps its policy. Ctrl+C always pauses.'}, {row: configRow('queueTimeline'), note: draft => draft.queue.liveTimeline ? 'On: queued work shows as a chronological timeline — recent results, the running command and what runs next' : 'Off: the one-line queue row and pending list, as before'}, diff --git a/src/shell/ShellSession.ts b/src/shell/ShellSession.ts index 5a8b989a..13cbb062 100644 --- a/src/shell/ShellSession.ts +++ b/src/shell/ShellSession.ts @@ -1,3 +1,4 @@ +import {execFileSync} from 'node:child_process'; import {shellAdapter} from './adapters/registry.js'; import type {ShellAdapter, ShellId} from './adapters/ShellAdapter.js'; import { randomBytes } from 'node:crypto'; @@ -281,6 +282,20 @@ export class ShellSession extends EventEmitter { try { this.pty.kill(); } finally { this.cleanup(); } } + /** + * After kill(): SIGKILL to the same PTY child when it ignored SIGHUP. node-pty may already have reaped the child + * (it reports the exit a moment later), so the pid is signalled only while it is still this process's child: a pid + * that is gone, or was reused by anything else, is never touched. Returns whether the signal was sent. + */ + forceKill(): boolean { + const pid = this.pty.pid; + let parent: number | undefined; + try { parent = Number(execFileSync('ps', ['-o', 'ppid=', '-p', String(pid)], {encoding: 'utf8', timeout: 1000}).trim()); } + catch { return false; } + if (parent !== process.pid) return false; + try { process.kill(pid, 'SIGKILL'); return true; } catch { return false; } + } + private receive(data: string): void { // Before readiness nothing answers the shell's startup queries, so input is never held for them. if (this.ready) this.order.observeOutput(data); diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index 8abe6d14..13a9d6bd 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -26,7 +26,7 @@ import {PROMPT_SYMBOL_IDS, promptSymbolLabel} from '../prompt/glyphChoices.js'; import {VIBRANCE_LABELS, VIBRANCE_LEVELS} from '../chroma/color.js'; import {OUTPUT_FOLDING_MODES} from '../output/FoldPolicy.js'; import {UPDATE_CHECK_FREQUENCIES, UPDATE_FREQUENCIES, UPDATE_MODES} from '../update/update.js'; -import {COMPOSER_POSITIONS, COMPOSER_POSITION_LABELS, LIVE_SESSION_MULTIPLE, LIVE_SESSION_STARTUP, TRANSCRIPT_PRESENTATIONS, +import {COMPOSER_POSITIONS, COMPOSER_POSITION_LABELS, LIVE_SESSION_MULTIPLE, LIVE_SESSION_STARTUP, SESSION_CLEANUP, SESSION_IDLE_EXPIRY, TRANSCRIPT_PRESENTATIONS, TRANSCRIPT_PRESENTATION_LABELS} from '../prompt/configuration.js'; import { DEFAULT_PROMPT_CONFIGURATION, @@ -438,6 +438,12 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [ enumRow({id: 'liveSessionMultiple', level: 'advanced', label: 'Multiple detached sessions', description: 'At launch with several: ask which, or open all in new windows', category: 'Sessions', values: LIVE_SESSION_MULTIPLE, labels: ['Ask which', 'Open all'], get: config => config.liveSessionMultiple, set: (config, liveSessionMultiple) => ({...config, liveSessionMultiple})}), + enumRow({id: 'sessionCleanup', label: 'Clean up unused sessions', description: 'End a detached session that never ran a command after it has been detached this long. Checked again just before; attached, protected or busy sessions are never ended, and the transcript is kept', category: 'Sessions', + values: SESSION_CLEANUP, labels: ['Off', '10 minutes', '30 minutes', '1 hour'], + get: config => config.sessionCleanup, set: (config, sessionCleanup) => ({...config, sessionCleanup})}), + enumRow({id: 'sessionIdleExpiry', level: 'advanced', label: 'End idle sessions', description: 'End a detached session after this long with no activity and no work (no command, input wait, queue, task, job or child process). Off by default; idle sessions are otherwise only suggested for review after a day', category: 'Sessions', + values: SESSION_IDLE_EXPIRY, labels: ['Off', 'After 1 day', 'After 3 days', 'After 7 days'], + get: config => config.sessionIdleExpiry, set: (config, sessionIdleExpiry) => ({...config, sessionIdleExpiry})}), {id: 'sessionNotices', label: 'Session notices', description: 'Brief factual lines (up to three) above the composer when other sessions finish, fail, ask for attention or end; each fades on its own, and /sessions keeps the state', category: 'Sessions', control: 'boolean', get: config => config.sessionNotices, set: (config, sessionNotices) => ({...config, sessionNotices})}, enumRow({id: 'terminalTitle', label: 'Terminal title', description: 'Set the window/tab title while NMSh owns the screen; programs set their own while they run and NMSh restores its title after them. Off leaves the title to the terminal', category: 'Sessions', diff --git a/tests/sessionLifecyclePolicy.test.ts b/tests/sessionLifecyclePolicy.test.ts new file mode 100644 index 00000000..698e8352 --- /dev/null +++ b/tests/sessionLifecyclePolicy.test.ts @@ -0,0 +1,82 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {POLICY_OFF, REVIEW_IDLE_MS, decodePolicy, eligibility, encodePolicy, lifecycleState, type LifecycleFacts, type LifecyclePolicy} from '../src/session/lifecycle.js'; + +const MIN = 60_000; +const HOUR = 60 * MIN; +const policy: LifecyclePolicy = {unusedMs: 10 * MIN, idleExpiryMs: 0}; +const facts = (extra: Partial = {}): LifecycleFacts => ({attached: false, ending: false, protected: false, running: false, waiting: false, + queued: 0, tasks: 0, used: false, detachedSince: 0, lastActivity: 0, policyAware: true, ...extra}); + +test('states come from observed facts only', () => { + assert.equal(lifecycleState(facts({attached: true, running: true})), 'attached'); + assert.equal(lifecycleState(facts({ending: true})), 'ending'); + assert.equal(lifecycleState(facts({running: true, waiting: true})), 'waiting'); + assert.equal(lifecycleState(facts({running: true})), 'executing'); + assert.equal(lifecycleState(facts({used: true, tasks: 1})), 'executing', 'an idle shell holding a background task is working'); + assert.equal(lifecycleState(facts({used: true, jobs: 2})), 'executing'); + assert.equal(lifecycleState(facts({used: true, queued: 1})), 'executing'); + assert.equal(lifecycleState(facts({used: true})), 'idle'); + assert.equal(lifecycleState(facts()), 'unused'); +}); + +test('an abandoned new session becomes eligible only after the grace period', () => { + const before = eligibility(facts(), policy, 9 * MIN); + assert.equal(before.kind, 'kept'); + assert.equal(before.kind === 'kept' && before.at, 10 * MIN, 'the service schedules exactly one check at the deadline'); + assert.match(before.reason, /never used; cleanup in 1m/); + const after = eligibility(facts(), policy, 10 * MIN); + assert.deepEqual(after.kind === 'automatic' && after.why, 'unused'); + // Reattaching before the deadline: attached is never eligible, and a new detach restarts the grace. + assert.equal(eligibility(facts({attached: true, detachedSince: undefined}), policy, 11 * MIN).kind, 'kept'); + assert.equal(eligibility(facts({detachedSince: 5 * MIN}), policy, 11 * MIN).kind, 'kept'); +}); + +test('work, protection, ending and unknown children always keep a session; idle output alone never makes it disposable', () => { + const now = 100 * HOUR; + const old = {detachedSince: 0, lastActivity: 0}; + for (const [extra, reason] of [ + [{protected: true}, /protected/], [{ending: true}, /ending/], [{attached: true}, /attached/], + [{running: true, used: true}, /command is running/], [{running: true, waiting: true, used: true}, /waiting for input/], + [{used: true, tasks: 1}, /1 background task/], [{used: true, jobs: 1}, /1 shell job/], [{used: true, queued: 3}, /3 queued commands/], + [{children: 2}, /2 child processes/], [{used: true, children: 1}, /1 child process\b/], + ] as Array<[Partial, RegExp]>) { + const result = eligibility(facts({...old, ...extra}), {unusedMs: 10 * MIN, idleExpiryMs: HOUR}, now); + assert.equal(result.kind, 'kept', JSON.stringify(extra)); + assert.match(result.reason, reason); + } +}); + +test('long-idle sessions are only suggested after a day; idle expiry is opt-in', () => { + const idle = facts({used: true, detachedSince: 0, lastActivity: 0}); + assert.equal(eligibility(idle, policy, REVIEW_IDLE_MS - 1).kind, 'kept'); + const review = eligibility(idle, policy, REVIEW_IDLE_MS); + assert.equal(review.kind, 'review', 'suggested, never automatic, with idle expiry off'); + assert.match(review.reason, /cleanup suggested/); + const expiring = eligibility(idle, {unusedMs: 0, idleExpiryMs: 3 * HOUR}, 3 * HOUR); + assert.deepEqual(expiring.kind === 'automatic' && expiring.why, 'idle-expired'); + // Idle expiry covers a never-used session too when unused cleanup is off, and a long-idle review carries the deadline. + const unusedExpiring = eligibility(facts({detachedSince: 0, lastActivity: 0}), {unusedMs: 0, idleExpiryMs: 3 * HOUR}, 3 * HOUR); + assert.deepEqual(unusedExpiring.kind === 'automatic' && unusedExpiring.why, 'idle-expired'); + const reviewing = eligibility(idle, {unusedMs: 0, idleExpiryMs: 3 * REVIEW_IDLE_MS}, REVIEW_IDLE_MS); + assert.equal(reviewing.kind === 'review' && reviewing.at, 3 * REVIEW_IDLE_MS, 'one timer at the expiry, not hourly wakeups'); + // Activity resets the clock: the deadline follows the latest prompt or output. + const active = eligibility(facts({used: true, lastActivity: 2 * HOUR}), {unusedMs: 0, idleExpiryMs: 3 * HOUR}, 3 * HOUR); + assert.equal(active.kind === 'kept' && active.at, 5 * HOUR); +}); + +test('a session created before any policy reached the service keeps its old behaviour: nothing automatic', () => { + const legacy = facts({policyAware: false}); + assert.equal(eligibility(legacy, policy, 9 * MIN).kind, 'kept'); + assert.equal(eligibility(legacy, policy, 11 * MIN).kind, 'review', 'offered behind a confirmation, never ended by a timer'); + assert.equal(eligibility(facts({policyAware: false, used: true}), {unusedMs: 0, idleExpiryMs: HOUR}, 30 * HOUR).kind, 'review'); + assert.equal(eligibility(facts(), POLICY_OFF, 11 * MIN).kind, 'review', 'cleanup off: offered, not automatic'); +}); + +test('the policy travels bounded; malformed text changes nothing', () => { + assert.equal(encodePolicy({unusedMs: 10 * MIN, idleExpiryMs: 0}), 'unused=600;idle=0'); + assert.deepEqual(decodePolicy('unused=600;idle=0'), {unusedMs: 10 * MIN, idleExpiryMs: 0}); + assert.deepEqual(decodePolicy('unused=1;idle=1'), {unusedMs: MIN, idleExpiryMs: HOUR}, 'clamped up to the minimum'); + assert.deepEqual(decodePolicy('unused=9999999;idle=99999999'), {unusedMs: 24 * HOUR, idleExpiryMs: 30 * 24 * HOUR}, 'clamped down to the maximum'); + for (const bad of [undefined, '', 'unused=-1;idle=0', 'unused=600', 'idle=0;unused=600', 'unused=600;idle=0;x=1']) assert.equal(decodePolicy(bad), undefined, String(bad)); +}); diff --git a/tests/sessionLifecycleService.test.ts b/tests/sessionLifecycleService.test.ts new file mode 100644 index 00000000..13f3b2ea --- /dev/null +++ b/tests/sessionLifecycleService.test.ts @@ -0,0 +1,358 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {chmodSync, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; +import {connect, type Socket} from 'node:net'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {SessionService, type SessionServiceOptions} from '../src/session/SessionService.js'; +import {FrameDecoder, PROTOCOL_VERSION, encodeMessage, type ClientMessage, type ServerMessage, type SessionInfo} from '../src/session/SessionProtocol.js'; +import {recoverEndedSessions} from '../src/session/recovery.js'; +import {readSpool} from '../src/session/StreamBacklog.js'; +import {spoolPathFor} from '../src/session/runtimeDir.js'; +import {TranscriptStore} from '../src/sessions/TranscriptStore.js'; + +/** + * The session service's lifecycle policy against real shells, with isolated runtime directories and short test + * timers. Nothing here touches the developer's own NMSh or Rex sessions: every service, socket and shell is created + * by the test and ended by it. + */ + +const SHORT = 300; + +class Peer { + readonly messages: ServerMessage[] = []; + readonly socket: Socket; + closed = false; + private readonly decoder = new FrameDecoder(); + constructor(path: string, hello: Partial> = {}) { + this.socket = connect(path); + this.socket.setEncoding('utf8'); + this.socket.on('data', chunk => { for (const r of this.decoder.push(chunk as unknown as string)) if (r.ok) this.messages.push(r.message as ServerMessage); }); + this.socket.on('close', () => { this.closed = true; }); + this.socket.on('error', () => {}); + this.send({type: 'hello', version: PROTOCOL_VERSION, client: 'test', features: 'input-state,queue,background-tasks', ...hello}); + } + send(message: ClientMessage): void { this.socket.write(encodeMessage(message)); } + count(type: string): number { return this.messages.filter(m => m.type === type).length; } + async waitFor(type: string, count = 1, timeoutMs = 15000): Promise { + await until(() => this.count(type) >= count, timeoutMs, `${type} #${count}`); + return this.messages.filter(m => m.type === type)[count - 1]!; + } + /** Run a line in the session this peer controls and wait for its prompt. */ + async run(line: string): Promise { + const prompts = this.count('prompt'); + this.send({type: 'input', data: `${line}\r`, submission: 1}); + await this.waitFor('prompt', prompts + 1); + } +} + +async function until(check: () => boolean | Promise, timeoutMs = 15000, what = 'condition'): Promise { + const deadline = Date.now() + timeoutMs; + while (!(await check())) { + if (Date.now() > deadline) throw new Error(`${what} not reached`); + await new Promise(resolve => setTimeout(resolve, 20)); + } +} + +const pause = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +function scratch(prefix: string): string { + const dir = realpathSync(mkdtempSync(join(tmpdir(), prefix))); + chmodSync(dir, 0o700); + return dir; +} + +interface Fixture { + service: SessionService; + home: string; + runtimeDir: string; + keeper: Peer; + /** Create a session through a fresh peer and wait for its first prompt. */ + create(): Promise<{peer: Peer; id: string; pid: number}>; + list(): Promise; + alive(id: string): boolean; +} + +async function withService(options: Partial, body: (fixture: Fixture) => Promise): Promise { + const runtimeDir = scratch('nmsh-lc-'); + const home = scratch('nmsh-lch-'); + const service = new SessionService({runtimeDir, startupIdleMs: 60_000, ...options}); + await service.start(); + // Holds the service open so ending the last session in a test never shuts it down mid-test. + const keeper = new Peer(service.socketPath); + const pids: number[] = []; + const fixture: Fixture = { + service, home, runtimeDir, keeper, + async create() { + const peer = new Peer(service.socketPath); + peer.send({type: 'create', cwd: home, env: {HOME: home, PATH: process.env.PATH ?? ''}, columns: 80, rows: 24}); + const created = await peer.waitFor('created'); + assert.equal(created.type, 'created'); + if (created.type !== 'created') throw new Error('not created'); + pids.push(created.pid); + await peer.waitFor('prompt'); + return {peer, id: created.sessionId, pid: created.pid}; + }, + async list() { + const lister = new Peer(service.socketPath); + lister.send({type: 'list'}); + const listed = await lister.waitFor('sessions'); + lister.socket.destroy(); + return listed.type === 'sessions' ? listed.sessions : []; + }, + alive: id => service.registry.some(record => record.id === id), + }; + try { await body(fixture); } + finally { + keeper.socket.destroy(); + await service.close(); + // Shells that ignore SIGHUP (one test makes one) are this test's own children, verified by the registry above. + for (const pid of pids) { try { process.kill(pid, 0); process.kill(pid, 'SIGKILL'); } catch { /* already gone */ } } + await pause(50); + rmSync(runtimeDir, {recursive: true, force: true}); + rmSync(home, {recursive: true, force: true}); + } +} + +const detach = async (fixture: Fixture, peer: Peer, id: string) => { + peer.socket.destroy(); + await until(() => fixture.service.registry.find(record => record.id === id)?.state === 'detached', 15000, 'detached'); +}; + +test('an abandoned new session ends after the grace period; its spool says why and its transcript is archived', async () => { + await withService({lifecycle: {unusedMs: SHORT, idleExpiryMs: 0}, childCount: async () => 0}, async fixture => { + const {peer, id} = await fixture.create(); + const listed = (await fixture.list()).find(session => session.id === id)!; + assert.equal(listed.used, undefined, 'never used'); + await detach(fixture, peer, id); + const detached = (await fixture.list()).find(session => session.id === id)!; + assert.ok(detached.detachedSince, 'the service reports when it lost its window'); + assert.match(detached.cleanup ?? '', /never used; cleanup in/); + await until(() => !fixture.alive(id), 15000, 'unused session ended'); + const spool = readSpool(spoolPathFor(fixture.runtimeDir, id)); + assert.match(spool.exit?.reason ?? '', /never used/); + const store = new TranscriptStore(join(fixture.home, 'transcripts')); + const recovered = await recoverEndedSessions(fixture.runtimeDir, store); + assert.equal(recovered.archived.length, 1, 'the transcript is kept, never discarded'); + const archived = await store.load(recovered.archived[0]!); + const text = (archived as unknown as {transcript: {lines: Array>}}).transcript.lines.map(line => line.map(cell => cell.text).join('')).join('\n'); + assert.match(text, /Ended by NMSh session cleanup: never used/); + }); +}); + +test('reattaching before the deadline cancels cleanup; a new detach starts a new grace period', async () => { + await withService({lifecycle: {unusedMs: 1500, idleExpiryMs: 0}, childCount: async () => 0}, async fixture => { + const {peer, id} = await fixture.create(); + await detach(fixture, peer, id); + await pause(700); + const back = new Peer(fixture.service.socketPath); + back.send({type: 'attach', sessionId: id, columns: 80, rows: 24}); + await back.waitFor('attached'); + await pause(1500); + assert.ok(fixture.alive(id), 'attached: never ended by a timer'); + const started = Date.now(); + await detach(fixture, back, id); + await until(() => !fixture.alive(id), 15000, 'ended after a full new grace'); + assert.ok(Date.now() - started >= 1300, 'the grace restarted at the new detach'); + }); +}); + +test('closing one of several frontends detaches only; the session is never ended by a window going away', async () => { + await withService({lifecycle: {unusedMs: 0, idleExpiryMs: 0}}, async fixture => { + const {peer, id, pid} = await fixture.create(); + const second = new Peer(fixture.service.socketPath); + second.send({type: 'attach', sessionId: id, columns: 80, rows: 24}); + const refused = await second.waitFor('error'); + assert.equal(refused.type === 'error' && refused.code, 'attached', 'one controlling window at a time'); + // A Rex pane or any window closing: the frontend's connection goes away, however abruptly. + await detach(fixture, peer, id); + process.kill(pid, 0); + second.send({type: 'attach', sessionId: id, columns: 80, rows: 24}); + await second.waitFor('attached'); + // Ending the session is a separate, explicit act. + second.send({type: 'terminate'}); + await second.waitFor('exit'); + assert.equal(fixture.alive(id), false); + }); +}); + +test('an idle shell that still holds work is never ended: jobs, child processes, background tasks, input waits, long commands', async () => { + let children: number | undefined = 0; + await withService({lifecycle: {unusedMs: SHORT, idleExpiryMs: 0}, childCount: async () => children}, async fixture => { + // A shell job: used, and the shell itself says it holds a job. + const job = await fixture.create(); + await job.peer.run('sleep 30 &'); + // A long foreground command (an agent, a build): running. + const running = await fixture.create(); + running.peer.send({type: 'input', data: 'sleep 30\r', submission: 1}); + await running.peer.waitFor('exec'); + // An input wait. + const waiting = await fixture.create(); + waiting.peer.send({type: 'input', data: 'read -r answer\r', submission: 1}); + await waiting.peer.waitFor('exec'); + // Never used, but the shell has a child process the policy cannot see as a job. + children = 1; + const child = await fixture.create(); + for (const session of [job, running, waiting, child]) await detach(fixture, session.peer, session.id); + await pause(SHORT * 4); + for (const session of [job, running, waiting, child]) assert.ok(fixture.alive(session.id), 'work keeps every one of them'); + const listed = await fixture.list(); + assert.match(listed.find(session => session.id === job.id)!.cleanup ?? '', /1 shell job/); + assert.match(listed.find(session => session.id === running.id)!.cleanup ?? '', /command is running/); + }); +}); + +test('unknown child processes preserve the session; the policy never guesses', async () => { + await withService({lifecycle: {unusedMs: SHORT, idleExpiryMs: 0}, childCount: async () => undefined}, async fixture => { + const {peer, id} = await fixture.create(); + await detach(fixture, peer, id); + await pause(SHORT * 4); + assert.ok(fixture.alive(id)); + }); +}); + +test('a timer that fires during an attach race never ends the newly attached session', async () => { + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + let asked = 0; + await withService({lifecycle: {unusedMs: SHORT, idleExpiryMs: 0}, childCount: async () => { asked += 1; await gate; return 0; }}, async fixture => { + const {peer, id} = await fixture.create(); + await detach(fixture, peer, id); + await until(() => asked > 0, 15000, 'the timer fired and is re-verifying'); + const back = new Peer(fixture.service.socketPath); + back.send({type: 'attach', sessionId: id, columns: 80, rows: 24}); + await back.waitFor('attached'); + release(); + await pause(300); + assert.ok(fixture.alive(id), 'eligibility was re-read after the check; attached wins'); + assert.equal(back.count('exit'), 0); + }); +}); + +test('a protected session is never ended by cleanup; unprotecting lets the policy apply again', async () => { + await withService({lifecycle: {unusedMs: SHORT, idleExpiryMs: 0}, childCount: async () => 0}, async fixture => { + const {peer, id} = await fixture.create(); + fixture.keeper.send({type: 'protect', sessionId: id, on: 1}); + await fixture.keeper.waitFor('sessions'); + await detach(fixture, peer, id); + await pause(SHORT * 4); + assert.ok(fixture.alive(id)); + assert.equal((await fixture.list()).find(session => session.id === id)!.protected, 1); + fixture.keeper.send({type: 'protect', sessionId: id, on: 0}); + await until(() => !fixture.alive(id), 15000, 'ended once unprotected'); + }); +}); + +test('idle expiry is off unless set; when set it ends only a used idle detached session', async () => { + await withService({lifecycle: {unusedMs: 0, idleExpiryMs: 0}, childCount: async () => 0}, async fixture => { + const {peer, id} = await fixture.create(); + await peer.run('true'); + await detach(fixture, peer, id); + await pause(SHORT * 3); + assert.ok(fixture.alive(id), 'off by default: a used idle session stays'); + }); + await withService({lifecycle: {unusedMs: 0, idleExpiryMs: SHORT}, childCount: async () => 0}, async fixture => { + const {peer, id} = await fixture.create(); + await peer.run('true'); + await detach(fixture, peer, id); + await until(() => !fixture.alive(id), 15000, 'idle expiry ended it'); + }); +}); + +test('sessions created before a policy reached the service keep their behaviour; a later policy does not end them', async () => { + await withService({childCount: async () => 0}, async fixture => { + const {peer, id} = await fixture.create(); + await detach(fixture, peer, id); + // A newer frontend now says what the person configured. + const newer = new Peer(fixture.service.socketPath, {lifecycle: 'unused=60;idle=3600'}); + newer.send({type: 'list'}); + const listed = await newer.waitFor('sessions'); + assert.equal(listed.type === 'sessions' && listed.sessions[0]!.cleanup, 'never used', 'offered for review, never timed'); + assert.ok(fixture.alive(id)); + }); +}); + +test('End All Eligible: the service re-verifies each kill and refuses one that has work, is protected, or is not eligible yet', async () => { + let offset = 0; + await withService({lifecycle: {unusedMs: 0, idleExpiryMs: 0}, childCount: async () => 0, now: () => Date.now() + offset}, async fixture => { + const busy = await fixture.create(); + busy.peer.send({type: 'input', data: 'sleep 30\r', submission: 1}); + await busy.peer.waitFor('exec'); + const guarded = await fixture.create(); + fixture.keeper.send({type: 'protect', sessionId: guarded.id, on: 1}); + const free = await fixture.create(); + for (const session of [busy, guarded, free]) await detach(fixture, session.peer, session.id); + const outcome = async (id: string) => { + const killer = new Peer(fixture.service.socketPath); + killer.send({type: 'kill', sessionId: id, when: 'eligible'}); + await until(() => killer.count('killed') + killer.count('error') > 0, 15000, 'kill answered'); + const reply = killer.messages.find(m => m.type === 'killed' || m.type === 'error')!; + killer.socket.destroy(); + return reply.type === 'error' ? `${reply.code}: ${reply.message}` : 'killed'; + }; + assert.match(await outcome(free.id), /^busy: never used/, 'just detached: not eligible yet, so not ended'); + offset = 2 * 24 * 60 * 60_000; + assert.match(await outcome(busy.id), /^busy: .*command is running/); + assert.match(await outcome(guarded.id), /^busy: protected/); + assert.equal(await outcome(free.id), 'killed', 'an unused idle session is eligible for an explicit, confirmed end'); + assert.ok(fixture.alive(busy.id)); + assert.ok(fixture.alive(guarded.id)); + }); +}); + +test('an explicit end of a shell that ignores SIGHUP escalates, so End never leaves a half-ended session', async () => { + await withService({killEscalationMs: 500}, async fixture => { + const {peer, id} = await fixture.create(); + await peer.run("trap '' HUP"); + await detach(fixture, peer, id); + const killer = new Peer(fixture.service.socketPath); + killer.send({type: 'kill', sessionId: id}); + await killer.waitFor('killed', 1, 10000); + assert.equal(fixture.alive(id), false); + assert.ok(existsSync(spoolPathFor(fixture.runtimeDir, id)), 'the spool stays for archiving'); + }); +}); + +test('a policy stated by another window never makes an older frontend\'s new session timer-eligible', async () => { + await withService({childCount: async () => 0}, async fixture => { + const newer = new Peer(fixture.service.socketPath, {lifecycle: 'unused=60;idle=0'}); + newer.send({type: 'list'}); + await newer.waitFor('sessions'); + // An older frontend (no lifecycle in its hello) creates a session after the policy reached the service. + const {peer, id} = await fixture.create(); + await detach(fixture, peer, id); + const listed = (await fixture.list()).find(session => session.id === id)!; + assert.equal(listed.cleanup, 'never used', 'offered for review only; no countdown'); + assert.equal(listed.cleanupAt, undefined); + }); +}); + +test('an unknown kill condition is refused, never read as an unconditional kill', async () => { + await withService({}, async fixture => { + const {peer, id} = await fixture.create(); + await detach(fixture, peer, id); + const killer = new Peer(fixture.service.socketPath); + killer.send({type: 'kill', sessionId: id, when: 'someday'}); + const refused = await killer.waitFor('error'); + assert.equal(refused.type === 'error' && refused.code, 'unsupported'); + await pause(200); + assert.ok(fixture.alive(id)); + }); +}); + +test('a shell still in its startup (an rc file waiting on a question) is busy, never an unused session to clean up', async () => { + await withService({lifecycle: {unusedMs: SHORT, idleExpiryMs: 0}, childCount: async () => 0}, async fixture => { + // The person's own .zshrc asks something before the first prompt, like an update check does. + writeFileSync(join(fixture.home, '.zshrc'), 'read -r answer\n'); + const peer = new Peer(fixture.service.socketPath); + peer.send({type: 'create', cwd: fixture.home, env: {HOME: fixture.home, PATH: process.env.PATH ?? ''}, columns: 80, rows: 24}); + const created = await peer.waitFor('created'); + const id = created.type === 'created' ? created.sessionId : ''; + await pause(300); + assert.equal(peer.count('prompt'), 0, 'the shell is still in its startup'); + await detach(fixture, peer, id); + await pause(SHORT * 4); + assert.ok(fixture.alive(id), 'never ended while its startup waits'); + assert.equal((await fixture.list()).find(session => session.id === id)!.lifecycle, 'executing'); + }); +}); diff --git a/tests/startupKillSafety.test.ts b/tests/startupKillSafety.test.ts new file mode 100644 index 00000000..aff27c89 --- /dev/null +++ b/tests/startupKillSafety.test.ts @@ -0,0 +1,215 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {chmodSync, mkdtempSync, realpathSync, rmSync} from 'node:fs'; +import {connect, type Socket} from 'node:net'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {endDetachedSession, type EndDetachedDeps} from '../src/session/liveSessions.js'; +import {askWithKill, createMultiPicker, multiPickerKey, pickWithKill, renderMultiPicker, type KillResult} from '../src/session/StartupPicker.js'; +import {SessionConnectError} from '../src/session/SocketSessionClient.js'; +import {SessionService} from '../src/session/SessionService.js'; +import {FrameDecoder, PROTOCOL_VERSION, encodeMessage, type ServerMessage, type SessionInfo} from '../src/session/SessionProtocol.js'; +import type {Key} from '../src/terminal/keys.js'; + +const info = (id: string, state: SessionInfo['state'], extra: Partial = {}): SessionInfo => + ({id, pid: 100, state, cwd: `/w/${id}`, createdAt: 1_000, ...extra}); +const text = (value: string): Key => ({kind: 'text', value}); +const key = (kind: string): Key => ({kind} as Key); + +/** A scripted service: what it lists, how a kill answers, and every call made to it. */ +function fakeService(listed: SessionInfo[] | Error, kill: (session: SessionInfo) => Promise = async () => {}, archive: () => Promise = async () => ({})) { + const calls: string[] = []; + const deps: EndDetachedDeps = { + list: async () => { calls.push('list'); if (listed instanceof Error) throw listed; return listed; }, + kill: async session => { calls.push(`kill ${session.id}`); await kill(session); }, + archive: async () => { calls.push('archive'); return archive(); }, + }; + return {deps, calls}; +} + +test('ending a session: only a shell this call ended counts as killed; every other answer is reported as it is', async () => { + // The service cannot be asked: a failure, never "already ended". + const unreachable = fakeService(new SessionConnectError('session service did not respond', 'timeout')); + await assert.rejects(endDetachedSession(info('a', 'detached'), unreachable.deps), /did not respond/); + assert.deepEqual(unreachable.calls, ['list'], 'nothing is killed when the service could not be asked'); + + // No longer listed: gone, and nothing is sent. + const gone = fakeService([info('b', 'detached')]); + assert.equal(await endDetachedSession(info('a', 'detached'), gone.deps), 'gone'); + assert.deepEqual(gone.calls, ['list']); + + // Attached since the picker opened: never touched. + const attached = fakeService([info('a', 'attached')]); + assert.equal(await endDetachedSession(info('a', 'detached'), attached.deps), 'attached'); + assert.deepEqual(attached.calls, ['list']); + + // Attached between the re-read and the kill: the service refuses, and that is "attached", not a failure or a kill. + const raced = fakeService([info('a', 'detached')], async () => { throw new SessionConnectError('session service refused: session is attached to another frontend', 'attached'); }); + assert.equal(await endDetachedSession(info('a', 'detached'), raced.deps), 'attached'); + assert.deepEqual(raced.calls, ['list', 'kill a']); + + // Ended between the re-read and the kill. + const ended = fakeService([info('a', 'detached')], async () => { throw new SessionConnectError('session service refused: no live session with that id', 'unknown'); }); + assert.equal(await endDetachedSession(info('a', 'detached'), ended.deps), 'gone'); + + // Any other kill failure propagates; the archive is never attempted. + const timeout = fakeService([info('a', 'detached')], async () => { throw new SessionConnectError('session service did not respond', 'timeout'); }); + await assert.rejects(endDetachedSession(info('a', 'detached'), timeout.deps), /did not respond/); + assert.deepEqual(timeout.calls, ['list', 'kill a']); + + // Killed, archived. + const killed = fakeService([info('a', 'detached')]); + assert.equal(await endDetachedSession(info('a', 'detached'), killed.deps), 'killed'); + assert.deepEqual(killed.calls, ['list', 'kill a', 'archive']); + + // Killed by this call, but archiving failed: still ended by us (never "already ended"), and said so. + const unarchived = fakeService([info('a', 'detached')], async () => {}, async () => { throw new Error('Another NMSh window is archiving this session.'); }); + assert.equal(await endDetachedSession(info('a', 'detached'), unarchived.deps), 'killed-unarchived'); +}); + +test('the kill targets the service\'s current record of the same id, never a remembered pid', async () => { + const seen: SessionInfo[] = []; + const {deps} = fakeService([info('a', 'detached', {pid: 999})], async session => { seen.push(session); }); + await endDetachedSession(info('a', 'detached', {pid: 100}), deps); + assert.equal(seen[0]!.pid, 999, 'the re-read record is what is sent; the kill itself is addressed by id'); +}); + +test('bulk confirmation names every target, by name, with no "and N more"', () => { + const sessions = Array.from({length: 12}, (_, index) => info(`s${index}`, 'detached', {cwd: '/w/same', signature: `Fruit${index}`})); + const state = createMultiPicker(sessions); + multiPickerKey(state, text('a')); + multiPickerKey(state, text('x')); + const screen = renderMultiPicker(state, 120, 5_000).join('\n'); + assert.match(screen, /Kill 12 detached sessions\?/); + for (let index = 0; index < 12; index += 1) assert.match(screen, new RegExp(`Fruit${index} · /w/same`), `session ${index} is listed`); + assert.doesNotMatch(screen, /more/); +}); + +test('bulk end with partial failures: each outcome is counted, failures stay listed and selected, nothing claims success', async () => { + const outcomes: Array = ['killed', new Error('session service did not respond'), 'killed-unarchived', 'gone']; + const screens: string[] = []; + let call = 0; + const run = async (render: (columns: number) => string[], onKey: (key: Key) => T | undefined): Promise => { + const keys = call === 0 ? [text('a'), text('x'), text('y')] : [text('n')]; + call += 1; + for (const k of keys) { const result = onKey(k); if (result !== undefined) return result; } + throw new Error('no result'); + }; + const sessions = ['a', 'b', 'c', 'd'].map(id => info(id, 'detached')); + const ids = await pickWithKill(sessions, {run: async (render, onKey) => { screens.push(render(400).join('\n')); return run(render, onKey); }, + now: () => 0, kill: async () => { const next = outcomes.shift()!; if (next instanceof Error) throw next; return next; }}); + assert.deepEqual(ids, []); + const summary = screens.find(screen => /ended/.test(screen)) ?? ''; + assert.match(summary, /1 session ended/); + assert.match(summary, /1 session ended but its transcript was not archived yet/); + assert.match(summary, /1 session had already ended/); + assert.match(summary, /1 session could not be ended \(session service did not respond\)/); + assert.match(summary, /1 detached live session\b/, 'only the failed one is still listed'); + assert.match(summary, /\/w\/b/); +}); + +test('one-session prompt: a kill the service refused or could not perform returns to the prompt and says why', async () => { + const outcomes: Array = ['attached', new Error('session service did not respond')]; + const screens: string[] = []; + const script: Key[][] = [[text('x')], [key('enter')], [text('x')], [key('enter')], [text('n')]]; + const run = async (render: (columns: number) => string[], onKey: (key: Key) => T | undefined): Promise => { + screens.push(render(120).join('\n')); + for (const k of script.shift() ?? []) { const result = onKey(k); if (result !== undefined) return result; } + throw new Error('no result'); + }; + const choice = await askWithKill(info('a', 'detached'), {run, now: () => 0, + kill: async () => { const next = outcomes.shift()!; if (next instanceof Error) throw next; return next; }}); + assert.equal(choice, 'not-now', 'neither refused kill was treated as a kill'); + assert.ok(screens.some(screen => /1 detached live session[\s\S]*attached in another window and was not touched/.test(screen))); + assert.ok(screens.some(screen => /Could not end that session: session service did not respond/.test(screen))); +}); + +test('one-session prompt: a killed or already-ended session lets startup continue fresh', async () => { + for (const outcome of ['killed', 'killed-unarchived', 'gone'] as const) { + const script: Key[][] = [[text('x')], [key('enter')]]; + const run = async (_render: unknown, onKey: (key: Key) => T | undefined): Promise => { + for (const k of script.shift() ?? []) { const result = onKey(k); if (result !== undefined) return result; } + throw new Error('no result'); + }; + assert.equal(await askWithKill(info('a', 'detached'), {run, kill: async () => outcome}), 'kill'); + } +}); + +class RawPeer { + readonly messages: ServerMessage[] = []; + readonly socket: Socket; + private readonly decoder = new FrameDecoder(); + constructor(path: string) { + this.socket = connect(path); + this.socket.setEncoding('utf8'); + this.socket.on('data', chunk => { for (const r of this.decoder.push(chunk as unknown as string)) if (r.ok) this.messages.push(r.message as ServerMessage); }); + this.socket.on('error', () => {}); + this.send({type: 'hello', version: PROTOCOL_VERSION, client: 'test'}); + } + send(message: Parameters[0]): void { this.socket.write(encodeMessage(message)); } + count(type: string): number { return this.messages.filter(m => m.type === type).length; } + async waitFor(type: string, count = 1): Promise { + await until(() => this.count(type) >= count); + return this.messages.filter(m => m.type === type)[count - 1]!; + } +} + +async function until(check: () => boolean, timeoutMs = 15000): Promise { + const deadline = Date.now() + timeoutMs; + while (!check()) { + if (Date.now() > deadline) throw new Error('condition not reached'); + await new Promise(resolve => setTimeout(resolve, 25)); + } +} + +function scratch(prefix: string): string { + const dir = realpathSync(mkdtempSync(join(tmpdir(), prefix))); + chmodSync(dir, 0o700); + return dir; +} + +test('service: once a confirmed kill is sent, no window can attach to the ending session before its shell exits', async () => { + const runtimeDir = scratch('nmsh-kr-'); + const home = scratch('nmsh-kh-'); + const service = new SessionService({runtimeDir, startupIdleMs: 60_000}); + await service.start(); + const keeper = new RawPeer(service.socketPath); + let pid = 0; + try { + const owner = new RawPeer(service.socketPath); + owner.send({type: 'create', cwd: home, env: {HOME: home, PATH: process.env.PATH ?? ''}, columns: 80, rows: 24}); + const created = await owner.waitFor('created'); + const id = created.type === 'created' ? created.sessionId : ''; + pid = created.type === 'created' ? created.pid : 0; + await owner.waitFor('prompt'); + // The shell takes a moment to exit after SIGHUP: the window in which an attach used to succeed. + owner.send({type: 'input', data: 'trap "sleep 1; exit 0" HUP\r'}); + await owner.waitFor('prompt', 2); + owner.socket.destroy(); + await until(() => service.registry[0]?.state === 'detached'); + + const killer = new RawPeer(service.socketPath); + killer.send({type: 'kill', sessionId: id}); + await until(() => (service as unknown as {sessions: Map}).sessions.get(id)?.ending === true); + const attacher = new RawPeer(service.socketPath); + attacher.send({type: 'attach', sessionId: id, columns: 80, rows: 24}); + const refused = await attacher.waitFor('error'); + assert.equal(refused.type === 'error' && refused.code, 'unknown'); + assert.match(refused.type === 'error' ? refused.message : '', /ending/); + assert.equal(attacher.count('attached'), 0); + // A second kill for the same session waits for the same exit. + const again = new RawPeer(service.socketPath); + again.send({type: 'kill', sessionId: id}); + await killer.waitFor('killed'); + await again.waitFor('killed'); + assert.equal(service.registry.length, 0); + for (const peer of [killer, attacher, again]) peer.socket.destroy(); + } finally { + keeper.socket.destroy(); + await service.close(); + if (pid) await until(() => { try { process.kill(pid, 0); return false; } catch { return true; } }, 5000).catch(() => {}); + await new Promise(resolve => setTimeout(resolve, 50)); + rmSync(runtimeDir, {recursive: true, force: true}); + rmSync(home, {recursive: true, force: true}); + } +}); diff --git a/tests/startupLifecyclePicker.test.ts b/tests/startupLifecyclePicker.test.ts new file mode 100644 index 00000000..a26a8e26 --- /dev/null +++ b/tests/startupLifecyclePicker.test.ts @@ -0,0 +1,157 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {cleanupSettingsKey, createMultiPicker, multiPickerKey, pickWithKill, renderCleanupSettings, renderMultiPicker, renderSinglePrompt, + type CleanupSettingsState, type KillResult} from '../src/session/StartupPicker.js'; +import {encodePolicy, policyFromSettings} from '../src/session/lifecycle.js'; +import {DEFAULT_PROMPT_CONFIGURATION, normalizePromptConfiguration} from '../src/prompt/configuration.js'; +import type {SessionInfo} from '../src/session/SessionProtocol.js'; +import type {Key} from '../src/terminal/keys.js'; + +const DAY = 24 * 60 * 60_000; +const info = (id: string, extra: Partial = {}): SessionInfo => + ({id, pid: 100, state: 'detached', cwd: `/w/${id}`, createdAt: 1_000, eligibility: 'kept', lifecycle: 'idle', cleanup: 'idle 5m', used: 1, ...extra}); +const text = (value: string): Key => ({kind: 'text', value}); +const key = (kind: string): Key => ({kind} as Key); + +/** Four sessions as a lifecycle-aware service reports them. */ +const fleet = () => [ + info('work', {lifecycle: 'executing', running: 'npm run build', runningSince: 0, cleanup: 'a command is running'}), + info('wait', {lifecycle: 'waiting', running: 'read -r x', runningSince: 0, inputSince: 0, inputConfidence: 'likely', cleanup: 'waiting for input'}), + info('old', {lifecycle: 'idle', idleSince: 1, eligibility: 'review', cleanup: 'idle 2d; cleanup suggested'}), + info('new', {lifecycle: 'unused', used: undefined, eligibility: 'kept', cleanup: 'never used; cleanup in 4m', cleanupAt: 5 * 60_000}), +]; + +test('the header counts working, waiting and idle sessions, plus what is not listed; rows say cleanup status', () => { + const state = createMultiPicker(fleet(), {attachedElsewhere: 1, endedWhileAway: 2}); + const screen = renderMultiPicker(state, 200, 2 * DAY).join('\n'); + assert.match(screen, /4 detached live sessions · 1 working · 1 waiting · 2 idle · 1 attached elsewhere · 2 ended while away/); + assert.match(screen, /\/w\/old\s+zsh\s+idle [^·;]+ · cleanup suggested$/m, 'the idle age is said once, by the activity column'); + assert.match(screen, /\/w\/new .*never used; cleanup in 4m/); + assert.doesNotMatch(screen.split('\n').find(line => line.includes('/w/work'))!, /cleanup/, 'work speaks for itself; no cleanup tag'); + assert.match(screen, /E end eligible \(1\) · I review idle · P protect · C cleanup settings/); +}); + +test('an older service reports no lifecycle facts: no counts, no tags and no lifecycle keys', () => { + const plain = fleet().map(({eligibility: _e, lifecycle: _l, cleanup: _c, cleanupAt: _a, ...rest}) => rest as SessionInfo); + const state = createMultiPicker(plain); + const screen = renderMultiPicker(state, 200, 0).join('\n'); + assert.doesNotMatch(screen, /working|E end eligible|cleanup/); + assert.equal(multiPickerKey(state, text('e')), undefined); + assert.equal(state.confirming, undefined, 'E does nothing without service facts'); + assert.equal(multiPickerKey(state, text('c')), undefined); +}); + +test('End All Eligible lists exactly the eligible sessions, with reasons, and asks once', () => { + const state = createMultiPicker(fleet()); + multiPickerKey(state, text('e')); + assert.deepEqual(state.confirming, ['old']); + const screen = renderMultiPicker(state, 200, 0).join('\n'); + assert.match(screen, /End 1 eligible session\?/); + assert.match(screen, /\/w\/old · zsh · idle 2d; cleanup suggested/); + assert.match(screen, /checked again just before it ends/); + assert.doesNotMatch(screen, /\/w\/work|\/w\/wait|\/w\/new/, 'nothing else is listed or ended'); + assert.deepEqual(multiPickerKey(state, key('enter')), {kill: ['old'], mode: 'eligible'}); + const none = createMultiPicker(fleet().filter(session => session.id !== 'old')); + multiPickerKey(none, text('E')); + assert.equal(none.confirming, undefined); + assert.match(none.message ?? '', /No session is eligible/); +}); + +test('Review Idle Sessions selects the long-idle ones; X then confirms exactly those', () => { + const state = createMultiPicker([...fleet(), info('older', {idleSince: 0, eligibility: 'review', cleanup: 'idle 3d; cleanup suggested'})]); + multiPickerKey(state, text('i')); + assert.deepEqual([...state.selected].sort(), ['old', 'older']); + assert.match(state.message ?? '', /Selected 2 sessions idle for over a day/); + multiPickerKey(state, text('x')); + assert.deepEqual(state.confirming, ['old', 'older']); + assert.match(renderMultiPicker(state, 200, 0).join('\n'), /Kill 2 detached sessions\?/); + // Suggested from a listing that cannot see child processes: the service re-checks each before ending it. + assert.deepEqual(multiPickerKey(state, key('enter')), {kill: ['old', 'older'], recheck: ['old', 'older']}); +}); + +test('End All Eligible through the picker: kills go out in eligible mode; a session the service kept stays listed', async () => { + const sessions = [...fleet(), info('stale', {idleSince: 0, eligibility: 'review', cleanup: 'idle 4d; cleanup suggested'})]; + const calls: string[] = []; + const outcomes: KillResult[] = ['killed', 'kept']; + const screens: string[] = []; + let call = 0; + const ids = await pickWithKill(sessions, {now: () => 0, + run: async (render: (columns: number) => string[], onKey: (key: Key) => T | undefined): Promise => { + screens.push(render(220).join('\n')); + const keys = call++ === 0 ? [text('e'), text('y')] : [text('n')]; + for (const k of keys) { const result = onKey(k); if (result !== undefined) return result; } + throw new Error('no result'); + }, + kill: async (session, mode) => { calls.push(`${session.id}:${mode}`); return outcomes.shift()!; }}); + assert.deepEqual(ids, []); + assert.deepEqual(calls, ['old:eligible', 'stale:eligible']); + assert.match(screens.at(-1)!, /1 session checked again and kept \(work, protection or not eligible yet\); 1 session ended/); + assert.match(screens.at(-1)!, /\/w\/stale/, 'the kept one is still listed'); +}); + +test('P protects through the service and redraws from its answer; C saves settings and refreshes the rows', async () => { + const protectedCalls: string[] = []; + let call = 0; + const screens: string[] = []; + let configured = 0; + const ids = await pickWithKill(fleet(), {now: () => 0, + run: async (render: (columns: number) => string[], onKey: (key: Key) => T | undefined): Promise => { + screens.push(render(220).join('\n')); + const keys = [[key('down'), key('down'), text('p')], [text('c')], [text('n')]][call++]!; + for (const k of keys) { const result = onKey(k); if (result !== undefined) return result; } + throw new Error('no result'); + }, + kill: async () => 'killed', + protect: async (session, on) => { protectedCalls.push(`${session.id}:${on}`); return {...session, protected: 1, eligibility: 'kept', cleanup: 'protected'}; }, + configure: async () => { configured += 1; return 'Cleanup settings saved; the session service applies them now.'; }, + // After the settings change the service no longer lists 'work' as detached (it was attached elsewhere meanwhile). + refresh: async () => fleet().map(session => session.id === 'work' ? {...session, state: 'attached' as const} : session)}); + assert.deepEqual(ids, []); + assert.deepEqual(protectedCalls, ['old:true']); + assert.match(screens[1]!, /\/w\/old .*protected/); + assert.match(screens[1]!, /Protected: cleanup and End All Eligible never end it/); + assert.equal(configured, 1); + assert.match(screens[2]!, /Cleanup settings saved/); + assert.doesNotMatch(screens[2]!, /\/w\/work/, 'refreshed: a session attached elsewhere drops out of the picker'); +}); + +test('the cleanup settings screen: ←→ change bounded values, Enter saves, Esc changes nothing', () => { + const state: CleanupSettingsState = {draft: {sessionCleanup: '10m', sessionIdleExpiry: 'off'}, cursor: 0}; + assert.match(renderCleanupSettings(state, 100).join('\n'), /› Clean up unused sessions\s+‹ 10 minutes ›[\s\S]*End idle sessions\s+‹ Off ›/); + cleanupSettingsKey(state, key('right')); + assert.equal(state.draft.sessionCleanup, '30m'); + cleanupSettingsKey(state, key('left')); cleanupSettingsKey(state, key('left')); + assert.equal(state.draft.sessionCleanup, 'off', 'wraps within the bounded list'); + cleanupSettingsKey(state, key('down')); + cleanupSettingsKey(state, key('right')); + assert.equal(state.draft.sessionIdleExpiry, '1d'); + assert.deepEqual(cleanupSettingsKey(state, key('enter')), {sessionCleanup: 'off', sessionIdleExpiry: '1d'}); + assert.equal(cleanupSettingsKey({draft: state.draft, cursor: 0}, key('escape')), null); +}); + +test('settings: unused cleanup defaults to 10 minutes, idle expiry to off; unknown values fall back; the policy is bounded', () => { + assert.equal(DEFAULT_PROMPT_CONFIGURATION.sessionCleanup, '10m'); + assert.equal(DEFAULT_PROMPT_CONFIGURATION.sessionIdleExpiry, 'off'); + const old = normalizePromptConfiguration({liveSessionStartup: 'ask'}); + assert.equal(old.sessionCleanup, '10m'); + assert.equal(old.sessionIdleExpiry, 'off', 'an upgraded configuration never gains idle expiry'); + const bogus = normalizePromptConfiguration({sessionCleanup: '1s', sessionIdleExpiry: 'forever'}); + assert.equal(bogus.sessionCleanup, '10m'); + assert.equal(bogus.sessionIdleExpiry, 'off'); + assert.equal(encodePolicy(policyFromSettings({sessionCleanup: '1h', sessionIdleExpiry: '7d'})), 'unused=3600;idle=604800'); + assert.equal(encodePolicy(policyFromSettings({sessionCleanup: 'off', sessionIdleExpiry: 'off'})), 'unused=0;idle=0'); +}); + +test('the one-session prompt says a session is protected or counting down to cleanup', () => { + assert.match(renderSinglePrompt(info('a', {protected: 1}), 100, 0).join('\n'), /^ {2}protected$/m); + assert.match(renderSinglePrompt(info('a', {lifecycle: 'unused', cleanup: 'never used; cleanup in 4m', cleanupAt: 1}), 100, 0).join('\n'), /never used; cleanup in 4m/); + assert.doesNotMatch(renderSinglePrompt(info('a'), 100, 0).join('\n'), /idle 5m$/m, 'kept without a deadline says nothing extra'); +}); + +test('End All Eligible: a session the service re-verified and kept is reported as kept, never as ended or failed', async () => { + const {endDetachedSession} = await import('../src/session/liveSessions.js'); + const {SessionConnectError} = await import('../src/session/SocketSessionClient.js'); + const outcome = await endDetachedSession(info('a'), {list: async () => [info('a')], archive: async () => ({}), + kill: async () => { throw new SessionConnectError('session service refused: a command is running', 'busy'); }}); + assert.equal(outcome, 'kept'); +}); diff --git a/tests/startupRestore.test.ts b/tests/startupRestore.test.ts index 179bd04b..e5c69c38 100644 --- a/tests/startupRestore.test.ts +++ b/tests/startupRestore.test.ts @@ -59,7 +59,7 @@ test('one-session prompt: identifying details, and R/Enter, N/Esc, A, D, and X t assert.equal(singlePromptKey(key('down')), undefined); }); -test('multi picker: move, Space toggles, A selects all then clears, Enter resumes selected in order, Esc none', () => { +test('multi picker: move, Space toggles, A selects all then clears, Enter resumes selected in order or else the highlighted one; N and Esc start new', () => { const sessions = [info('s1', 'detached'), info('s2', 'detached', {running: 'npm test'}), info('s3', 'detached')]; const state = createMultiPicker(sessions); assert.match(renderMultiPicker(state, 100, 61_000).join('\n'), /3 detached live sessions[\s\S]*› · \/w\/s1[\s\S]*npm test/); @@ -77,9 +77,11 @@ test('multi picker: move, Space toggles, A selects all then clears, Enter resume assert.equal(state.selected.size, 3); multiPickerKey(state, text('A')); assert.equal(state.selected.size, 0, 'A again clears everything'); - assert.deepEqual(multiPickerKey(state, key('enter')), [], 'Enter with nothing selected resumes none'); + assert.deepEqual(multiPickerKey(state, key('enter')), ['s1'], 'Enter with nothing selected resumes the highlighted session, as the footer says'); + assert.match(renderMultiPicker(state, 100, 61_000).join('\n'), /Enter resume this · N new session/); multiPickerKey(state, text('a')); - assert.deepEqual(multiPickerKey(state, key('escape')), [], 'Esc resumes none even with a selection'); + assert.deepEqual(multiPickerKey(state, key('escape')), [], 'Esc starts a new session even with a selection'); + assert.deepEqual(multiPickerKey(state, text('n')), [], 'N starts a new session; every listed one keeps running'); assert.equal(multiPickerKey(state, text('k')), undefined, 'K is not a key'); }); @@ -207,7 +209,7 @@ test('multi picker: X only opens a confirmation; Esc, N and Ctrl+C cancel; Enter for (const cancel of [key('escape'), text('n'), text('N'), key('interrupt')]) { const state = createMultiPicker(three()); assert.equal(multiPickerKey(state, text('x')), undefined, 'X alone kills nothing'); - assert.equal(state.confirming, 'a'); + assert.deepEqual(state.confirming, ['a'], 'nothing selected: the highlighted session'); assert.match(renderMultiPicker(state, 80, 5_000).join('\n'), /Kill detached session\?[\s\S]*remain available in \/resume[\s\S]*Enter\/Y kill · Esc\/N cancel/); assert.equal(multiPickerKey(state, cancel), undefined); assert.equal(state.confirming, undefined); assert.equal(state.sessions.length, 3); @@ -215,14 +217,14 @@ test('multi picker: X only opens a confirmation; Esc, N and Ctrl+C cancel; Enter for (const confirm of [key('enter'), text('y'), text('Y')]) { const state = createMultiPicker(three()); state.cursor = 1; multiPickerKey(state, text('X')); - assert.deepEqual(multiPickerKey(state, confirm), {kill: 'b'}); + assert.deepEqual(multiPickerKey(state, confirm), {kill: ['b']}); } const state = createMultiPicker(three()); multiPickerKey(state, text('x')); assert.equal(multiPickerKey(state, text(' ')), undefined, 'Space is inert while confirming'); assert.equal(multiPickerKey(state, text('a')), undefined, 'A is inert while confirming'); assert.equal(state.selected.size, 0); - assert.match(renderMultiPicker(createMultiPicker(three()), 100, 0).join('\n'), /↑↓ move · Space select · A all · X kill · Enter resume none · Esc none/); + assert.match(renderMultiPicker(createMultiPicker(three()), 100, 0).join('\n'), /Enter resume this · N new session · ↑↓ move · Space select · A all[\s\S]*X kill this · Esc new session/); assert.equal(killConfirmKey(text('k')), undefined); }); @@ -246,7 +248,8 @@ test('pickWithKill: kills through the injected path, drops selection, and a fina throw new Error('script ended without a result'); }; const ids = await pickWithKill(three(), {run, kill: async session => { killed.push(session.id); return 'killed'; }, now: () => 5_000}); - assert.deepEqual(killed, ['b', 'c', 'a'], 'every kill went through the injected kill, in order'); + // The first X has a selection (a), so it kills the selection, not the highlighted b; then the highlighted ones. + assert.deepEqual(killed, ['a', 'c', 'b'], 'every kill went through the injected kill, in order'); assert.deepEqual(ids, [], 'no sessions left: resolves with none so startup continues fresh'); assert.ok(screens.some(screen => /Session ended; its transcript is in \/resume/.test(screen))); }); @@ -255,7 +258,7 @@ test('pickWithKill: already-gone and attached-elsewhere sessions refresh safely; const outcomes: KillResult[] = ['gone', 'attached']; let call = 0; const run = async (_render: unknown, onKey: (key: Key) => T | undefined): Promise => { - const keys = call === 0 ? [text('x'), text('y')] : call === 1 ? [text('x'), key('enter')] : call === 2 ? [text('x'), text('y')] : [key('enter')]; + const keys = call === 0 ? [text('x'), text('y')] : call === 1 ? [text('x'), key('enter')] : call === 2 ? [text('x'), text('y')] : [text('n')]; call += 1; for (const k of keys) { const result = onKey(k); if (result !== undefined) return result; } throw new Error('no result'); @@ -268,7 +271,7 @@ test('pickWithKill: already-gone and attached-elsewhere sessions refresh safely; throw new Error('permission denied'); }}); assert.equal(attempts, 3); - assert.deepEqual(ids, [], 'Enter with nothing selected; the failed row was kept, not lost'); + assert.deepEqual(ids, [], 'N: a new session; the failed row was kept, not lost'); }); test('one-session prompt: X confirms before killing; cancel returns to the prompt; Enter is still Resume', async () => { @@ -290,3 +293,54 @@ test('restoreAtStartup: a killed single session starts fresh and attaches nothin askOne: async () => 'kill', pick: async () => assert.fail('not used'), host: detectTerminalHost({}), selfCommand: ['nmsh']}; assert.deepEqual(await restoreAtStartup([info('a', 'detached')], deps), {}); }); + +test('multi picker: X with a selection asks once for all of them, naming the count; A then X is Kill all', async () => { + const state = createMultiPicker(three()); + multiPickerKey(state, text(' ')); + multiPickerKey(state, key('down')); + multiPickerKey(state, key('down')); + multiPickerKey(state, text(' ')); + assert.match(renderMultiPicker(state, 100, 0).join('\n'), /Enter resume 2 selected[\s\S]*X kill 2 selected/); + multiPickerKey(state, text('x')); + assert.deepEqual(state.confirming, ['a', 'c'], 'the selected sessions, never the highlighted one alone'); + const screen = renderMultiPicker(state, 100, 0).join('\n'); + assert.match(screen, /Kill 2 detached sessions\?/); + assert.match(screen, /\/w\/a[\s\S]*\/w\/c/); + assert.match(screen, /ends 2 live shells[\s\S]*not touched[\s\S]*Enter\/Y kill all 2 · Esc\/N cancel/); + assert.deepEqual(multiPickerKey(state, text('y')), {kill: ['a', 'c']}); + const all = createMultiPicker(three()); + multiPickerKey(all, text('a')); + multiPickerKey(all, text('x')); + assert.match(renderMultiPicker(all, 100, 0).join('\n'), /Kill 3 detached sessions\?[\s\S]*Enter\/Y kill all 3/); + assert.deepEqual(multiPickerKey(all, key('enter')), {kill: ['a', 'b', 'c']}); + // Performed one at a time; an attached-elsewhere one stays listed, and the line says what happened to each. + const outcomes: KillResult[] = ['killed', 'attached', 'killed']; + const screens: string[] = []; + let call = 0; + const run = async (render: (columns: number) => string[], onKey: (key: Key) => T | undefined): Promise => { + const keys = call === 0 ? [text('a'), text('x'), text('y')] : [text('n')]; + call += 1; + for (const k of keys) { screens.push(render(100).join('\n')); const result = onKey(k); if (result !== undefined) return result; } + screens.push(render(100).join('\n')); + throw new Error('no result'); + }; + const killed: string[] = []; + const ids = await pickWithKill(three(), {run, now: () => 0, kill: async session => { killed.push(session.id); return outcomes.shift()!; }}); + assert.deepEqual(killed, ['a', 'b', 'c']); + assert.deepEqual(ids, []); + assert.ok(screens.some(screen => /1 session attached in another window was not touched; 2 sessions ended/.test(screen)), screens.at(-1)); + assert.ok(screens.some(screen => /\/w\/b/.test(screen) && /1 detached live session\b/.test(screen)), 'the attached one is still listed'); +}); + +test('picker rows name the shell and say what the service saw: idle, running, waiting for input; never a guessed zsh', () => { + const sessions = [info('a', 'detached', {shell: 'fish', idleSince: 1, lastExit: 0, signature: 'Lemon'}), + info('b', 'detached', {shell: 'bash', running: 'read -r name', runningSince: 0, inputSince: 0, inputConfidence: 'likely'}), + info('c', 'detached', {running: 'npm test', runningSince: 0, lastOutputAt: 59_000})]; + const screen = renderMultiPicker(createMultiPicker(sessions), 120, 60_001).join('\n'); + assert.match(screen, /Lemon\s+\/w\/a\s+fish\s+idle 1m/); + assert.match(screen, /\/w\/b\s+bash\s+running read -r name · 1m · probably waiting for input 1m/); + assert.match(screen, /\/w\/c\s+zsh\s+running npm test · 1m · active/); + const single = renderSinglePrompt(sessions[0]!, 100, 60_001).join('\n'); + assert.match(single, /Lemon · \/w\/a · fish/); + assert.match(single, /idle at the prompt · 1m/); +});