From 18c0f21d6bf98260bb34f15bf6a3fdd25b16ab3e Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:09:15 +0530 Subject: [PATCH] Show evidence-based status for live sessions /resume LIVE rows and nmsh --list now say what each live session is doing, from evidence the service already sees rather than guesses. Per session the service records when output last arrived, the title the running program set (OSC 0/2), whether it asked for attention (an OSC 9 or OSC 777 notification, or a bell, while a command runs; cleared by typing or by the command ending) and the last exit code. The foreground process name is read from node-pty only when sessions are listed, so nothing polls. A pure liveStatusParts turns that into factual clauses: running command and elapsed time, a known CLI's display name or the foreground process, active or quiet output, needs attention, fullscreen, the program's title, and the last command's result while idle. Known CLIs (Claude Code, Codex, Aider, ...) only get a display name; every program gets the same states, and nothing claims what a program is thinking or waiting for. The new SessionInfo fields are optional, so older services still decode. Paths under the home directory show as ~/... in /resume rows, sharing the startup picker's helper. --- CHANGELOG.md | 1 + docs/design/session-interaction-ux.md | 7 ++ src/session/SessionEvidence.ts | 80 ++++++++++++++ src/session/SessionProtocol.ts | 16 ++- src/session/SessionService.ts | 22 +++- src/session/StartupPicker.ts | 4 +- src/session/liveStatus.ts | 57 ++++++++++ src/session/sessionList.ts | 9 +- src/sessions/ResumeBrowser.ts | 8 +- src/shell/ShellSession.ts | 5 + tests/liveStatus.test.ts | 143 ++++++++++++++++++++++++++ 11 files changed, 339 insertions(+), 13 deletions(-) create mode 100644 src/session/SessionEvidence.ts create mode 100644 src/session/liveStatus.ts create mode 100644 tests/liveStatus.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b53945..746fb48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added +- **Live session status:** `/resume` LIVE rows and `nmsh --list` show what each live session is doing, from evidence only: the running command and elapsed time, the foreground process or a known CLI's name (Claude Code, Codex, Aider, …), active or quiet output, *needs attention* when the program sent a terminal notification or bell, fullscreen, the title it set, and the last command's result while idle. Nothing is guessed, and paths under your home directory are shown as `~/…`. - **`/layout` showcase:** preview composer position (Bottom, Top, Flow) × transcript presentation (Normal, Chat) on sample content through the real renderer, then save and apply live. Also under Config → Layout. Nothing in the preview runs or reaches the transcript, journal or `/copy`. - **Flow composer:** Config → Composer position → Flow (the command palette's Toggle composer position cycles Bottom, Top and Flow). The prompt and input follow the newest output inside NMSh's document, like a conventional terminal, and scroll with it. Typing while scrolled back returns to them; scrolling alone does not. Menus open below the input, panels pin to the bottom, and Chat presentation and fullscreen passthrough work as before. - **Startup restore is your choice:** Config → Sessions → Startup restore (Ask, the default; Always; or Never) and Multiple detached sessions (Ask which, or Open all). With one detached session, NMSh asks: Resume, Not now, Always or Don't resume at startup. With several, a picker restores the ones you select: this window takes one, and the others open in new Ghostty, Terminal.app or kitty windows. Where a host can't open windows, NMSh names the `nmsh --attach` command for each. Never only skips restoring at launch; it never ends a session. diff --git a/docs/design/session-interaction-ux.md b/docs/design/session-interaction-ux.md index 210f277..2190e25 100644 --- a/docs/design/session-interaction-ux.md +++ b/docs/design/session-interaction-ux.md @@ -138,6 +138,13 @@ Live sessions are owned by the per-user session service (`nmshd`), which listens - Terminal.app: AppleScript `do script`. macOS asks once for Automation permission. - kitty: `kitten @ launch --type=os-window`, which needs kitty remote control. - Hosts without a way to open windows (VS Code, Zed, others), or a launcher that fails: the remaining sessions keep running, and this window names the `nmsh --attach` command for each one. +- **Live status (#174):** `/resume` LIVE rows and `nmsh --list` show evidence-based status for each live session. The service gathers it from the session's own output and reads it only when sessions are listed. Nothing polls, and nothing reads the screen. + - What runs and for how long. The foreground process is shown when it differs from the command (for example `process node` for `npm test`). A known interactive CLI (Claude Code, Codex, Aider, Gemini CLI, OpenCode, Goose, …) gets its display name. The table only supplies names: every program gets the same states. + - Output recency: *active* if the session wrote in the last 10 s, otherwise *quiet* for how long. + - *needs attention*: the running program asked for it with a terminal notification (OSC 9 excluding progress, OSC 777 notify) or a bell. It clears when someone types into the session or the command ends. + - *fullscreen* while the program holds the alternate screen, and the window title it set (OSC 0/2, sanitized and bounded). + - While idle: how long, and whether the last command succeeded or failed with its exit code. + - NMSh never claims what a program is doing or waiting for (thinking, approval, input). What the stream does not say is not shown. Status lives in the service, so it survives detach and reattach. Older services omit it, and the rows simply show less. - **Idle age:** `/resume` shows how long each idle live session has been at its prompt. Known limitations: diff --git a/src/session/SessionEvidence.ts b/src/session/SessionEvidence.ts new file mode 100644 index 0000000..131a4f3 --- /dev/null +++ b/src/session/SessionEvidence.ts @@ -0,0 +1,80 @@ +/** + * Cheap, factual evidence about what a live session's foreground program is + * doing, gathered from its own output stream: when it last wrote, the title it + * set, and whether it asked for attention. Nothing here guesses intent; what + * the stream does not say stays unknown. + */ + +// OSC sequences end in BEL or ST; everything up to the terminator is payload. +const OSC = /\u001b\](\d+);([^\u0007\u001b]*)(?:\u0007|\u001b\\)/g; +const TITLE_LIMIT = 80; + +export interface SessionEvidenceSnapshot { + lastOutputAt?: number; + /** The window title the foreground program set (OSC 0/2), sanitized. */ + title?: string; + /** When the program asked for attention (a terminal notification or bell) while a command ran. */ + attentionSince?: number; + /** Exit code of the last finished command. */ + lastExit?: number; +} + +export class SessionEvidence { + private lastOutputAt?: number; + private title?: string; + private attentionSince?: number; + private lastExit?: number; + private running = false; + private carry = ''; + + observe(data: string, now: number): void { + this.lastOutputAt = now; + let text = this.carry + data; + // Keep an unfinished OSC for the next chunk so a split sequence is still seen. + const open = text.lastIndexOf('\u001b]'); + this.carry = ''; + if (open !== -1 && !/\u0007|\u001b\\/u.test(text.slice(open)) && text.length - open < 512) { + this.carry = text.slice(open); + text = text.slice(0, open); + } + let attention = false; + const rest = text.replace(OSC, (_match, code: string, payload: string) => { + if (code === '0' || code === '2') { + const title = payload.replace(/[\u0000-\u001f\u007f]/gu, '').trim().slice(0, TITLE_LIMIT); + this.title = title || undefined; + } else if (code === '9' && !payload.startsWith('4;')) attention = true; // OSC 9 notification; 9;4 is progress + else if (code === '777' && payload.startsWith('notify;')) attention = true; + return ''; + }); + if (rest.includes('\u0007')) attention = true; + if (attention && this.running && this.attentionSince === undefined) this.attentionSince = now; + } + + /** A command started: a new program, so its title and attention start fresh. */ + onExec(): void { + this.running = true; + this.title = undefined; + this.attentionSince = undefined; + } + + onPrompt(exitCode: number): void { + this.running = false; + this.lastExit = exitCode; + this.title = undefined; + this.attentionSince = undefined; + } + + /** Someone typed into the session: any attention request has been seen. */ + onInput(): void { + this.attentionSince = undefined; + } + + snapshot(): SessionEvidenceSnapshot { + return { + ...(this.lastOutputAt !== undefined ? {lastOutputAt: this.lastOutputAt} : {}), + ...(this.title ? {title: this.title} : {}), + ...(this.attentionSince !== undefined ? {attentionSince: this.attentionSince} : {}), + ...(this.lastExit !== undefined ? {lastExit: this.lastExit} : {}), + }; + } +} diff --git a/src/session/SessionProtocol.ts b/src/session/SessionProtocol.ts index 1e12c1d..71a3556 100644 --- a/src/session/SessionProtocol.ts +++ b/src/session/SessionProtocol.ts @@ -65,6 +65,19 @@ export interface SessionInfo { idleSince?: number; /** Journal of the session's most recent frontend, as it last acknowledged. */ journalId?: string; + // Evidence for /resume status (#174); absent from older services, and unknown stays absent. + /** Foreground process name while a command runs, when the platform can tell. */ + process?: string; + /** 1 while the running program holds the alternate screen. */ + fullscreen?: number; + /** When the session last produced output. */ + lastOutputAt?: number; + /** Title the running program set for its window (OSC 0/2). */ + title?: string; + /** When the running program asked for attention (terminal notification or bell) and nobody has typed since. */ + attentionSince?: number; + /** Exit code of the last finished command, while idle. */ + lastExit?: number; } export type ProtocolMessage = ClientMessage | ServerMessage; @@ -112,7 +125,8 @@ 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?'}; + running: 'string?', runningSince: 'int?', idleSince: 'int?', journalId: 'string?', + process: 'string?', fullscreen: 'int?', lastOutputAt: 'int?', title: 'string?', attentionSince: 'int?', lastExit: 'int?'}; function validField(kind: Kind, value: unknown): boolean { switch (kind) { diff --git a/src/session/SessionService.ts b/src/session/SessionService.ts index 57cbff3..9a39cba 100644 --- a/src/session/SessionService.ts +++ b/src/session/SessionService.ts @@ -2,6 +2,7 @@ import {randomUUID} from 'node:crypto'; import {chmodSync, lstatSync, unlinkSync} from 'node:fs'; import {connect, createServer, type Server, type Socket} from 'node:net'; import {ShellSession} from '../shell/ShellSession.js'; +import {SessionEvidence} from './SessionEvidence.js'; import {FrameDecoder, PROTOCOL_VERSION, encodeMessage, type ServerMessage, type SessionInfo, type SessionState} from './SessionProtocol.js'; import {SESSION_MODE_ENV} from './SessionClient.js'; import {ensurePrivateRuntimeDir, socketPathFor, spoolPathFor} from './runtimeDir.js'; @@ -33,6 +34,8 @@ interface ManagedSession { resizes: number; seq: number; backlog: StreamBacklog; + /** What the foreground program's own output says: recency, title, attention, last exit. */ + evidence: SessionEvidence; } // DECSET/DECRST 1049, 1047 and 47: the alternate-screen switches. @@ -166,9 +169,18 @@ export class SessionService { private info(session: ManagedSession): SessionInfo { const {record, running} = session; + const evidence = session.evidence.snapshot(); + // Read only when someone lists sessions; nothing polls the process table. + const process = running ? session.shell.foregroundProcess : undefined; return {id: record.id, pid: record.pid, state: record.state, cwd: record.cwd, createdAt: Date.parse(record.createdAt), ...(running ? {running: running.command, runningSince: running.since} : {idleSince: session.idleSince}), - ...(session.backlog.journalId ? {journalId: session.backlog.journalId} : {})}; + ...(session.backlog.journalId ? {journalId: session.backlog.journalId} : {}), + ...(process && process !== 'zsh' ? {process} : {}), + ...(running && session.screen.active ? {fullscreen: 1} : {}), + ...(evidence.lastOutputAt !== undefined ? {lastOutputAt: evidence.lastOutputAt} : {}), + ...(evidence.title ? {title: evidence.title} : {}), + ...(evidence.attentionSince !== undefined ? {attentionSince: evidence.attentionSince} : {}), + ...(evidence.lastExit !== undefined && !running ? {lastExit: evidence.lastExit} : {})}; } async start(): Promise { @@ -268,7 +280,7 @@ export class SessionService { case 'list': send({type: 'sessions', sessions: [...this.sessions.values()].map(session => this.info(session))}); break; - case 'input': owned?.shell.write(message.data); break; + case 'input': owned?.evidence.onInput(); owned?.shell.write(message.data); break; case 'resize': if (owned) { owned.resizes += 1; owned.shell.resize(message.columns, message.rows); } break; @@ -327,7 +339,8 @@ export class SessionService { const record: SessionRecord = {id: randomUUID(), pid: shell.pid, cwd, createdAt: new Date().toISOString(), state: 'attached', protocolVersion: PROTOCOL_VERSION}; const session: ManagedSession = {record, shell, controller: send, idleSince: Date.now(), screen: new AlternateScreenTracker(), resizes: 0, - seq: 0, backlog: new StreamBacklog(spoolPathFor(this.options.runtimeDir, record.id), this.options.backlogLimits)}; + seq: 0, backlog: new StreamBacklog(spoolPathFor(this.options.runtimeDir, record.id), this.options.backlogLimits), + evidence: new SessionEvidence()}; this.sessions.set(record.id, session); // Every event is retained until a frontend journal acknowledges it, and // sent live when a frontend is attached. @@ -337,6 +350,7 @@ export class SessionService { }; shell.on('data', data => { const at = Date.now(); + session.evidence.observe(data, at); session.screen.observeModes(data); const kept = session.screen.push(data); if (kept) emit({kind: 'output', seq: ++session.seq, at, data: kept}, {type: 'output', data, seq: session.seq, at}); @@ -345,12 +359,14 @@ export class SessionService { shell.on('exec', command => { const at = Date.now(); session.running = {command, since: at}; + session.evidence.onExec(); emit({kind: 'exec', seq: ++session.seq, at, command}); }); shell.on('prompt', marker => { record.cwd = marker.cwd; session.running = undefined; session.idleSince = Date.now(); + session.evidence.onPrompt(marker.exitCode); session.screen.reset(); emit({kind: 'prompt', seq: ++session.seq, at: Date.now(), exitCode: marker.exitCode, cwd: marker.cwd}); }); diff --git a/src/session/StartupPicker.ts b/src/session/StartupPicker.ts index 568aa3e..2487872 100644 --- a/src/session/StartupPicker.ts +++ b/src/session/StartupPicker.ts @@ -1,13 +1,11 @@ -import {homedir} from 'node:os'; import {KeyDecoder, type Key} from '../terminal/keys.js'; import type {SessionInfo} from './SessionProtocol.js'; -import {formatAge} from './sessionList.js'; +import {formatAge, tildePath} from './sessionList.js'; // Launch-time restore screens, shown before any session is attached. Neither // has a destructive key: killing a live session stays a confirmed /resume action. const clipTo = (columns: number) => (text: string) => (text.length > columns - 1 ? `${text.slice(0, Math.max(0, columns - 2))}…` : text); -const tildePath = (path: string, home = homedir()) => (home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path); const activity = (session: SessionInfo) => session.running?.replace(/\s+/gu, ' ').slice(0, 60) ?? 'zsh'; const age = (session: SessionInfo, now: number) => formatAge(now - (session.runningSince ?? session.idleSince ?? session.createdAt)); diff --git a/src/session/liveStatus.ts b/src/session/liveStatus.ts new file mode 100644 index 0000000..37716db --- /dev/null +++ b/src/session/liveStatus.ts @@ -0,0 +1,57 @@ +import type {SessionInfo} from './SessionProtocol.js'; +import {formatAge} from './sessionList.js'; + +/** + * Display names for well-known interactive CLIs, agents included. Labels only: + * every program gets the same evidence and the same states, recognized or not. + */ +export const KNOWN_CLI_NAMES: Readonly> = { + claude: 'Claude Code', codex: 'Codex', aider: 'Aider', gemini: 'Gemini CLI', opencode: 'OpenCode', + goose: 'Goose', 'cursor-agent': 'Cursor Agent', amp: 'Amp', crush: 'Crush', qwen: 'Qwen Code', +}; + +/** Output newer than this reads as active; older as quiet. */ +export const ACTIVE_OUTPUT_MS = 10_000; + +const baseName = (path: string) => path.slice(path.lastIndexOf('/') + 1); + +/** The program word of a command line, skipping leading VAR=value assignments. */ +export function commandWord(command: string): string | undefined { + for (const word of command.trim().split(/\s+/u)) { + if (!word || /^[A-Za-z_][A-Za-z0-9_]*=/u.test(word)) continue; + return baseName(word); + } + return undefined; +} + +/** A known CLI's display name, from the command line or the foreground process. */ +export function knownProgram(session: SessionInfo): string | undefined { + const word = session.running ? commandWord(session.running) : undefined; + return (word && KNOWN_CLI_NAMES[word]) || (session.process && KNOWN_CLI_NAMES[baseName(session.process)]) || undefined; +} + +/** + * Factual status from service evidence. Every clause is something the session + * reported: what runs, whether it wrote recently, whether it asked for + * attention, the title it set, or how the last command ended. Nothing is + * inferred about what a program is waiting for; unknown stays unsaid. + */ +export function liveStatusParts(session: SessionInfo, now: number): string[] { + if (!session.running) { + const parts = [`idle${session.idleSince ? ` ${formatAge(now - session.idleSince)}` : ''}`]; + if (session.lastExit !== undefined) parts.push(session.lastExit === 0 ? 'last command succeeded' : `last command failed (exit ${session.lastExit})`); + return parts; + } + const parts = [`running ${session.running.replace(/\s+/gu, ' ').slice(0, 60)} · ${formatAge(now - (session.runningSince ?? now))}`]; + const known = knownProgram(session); + if (known) parts.push(known); + else if (session.process && session.process !== commandWord(session.running)) parts.push(`process ${session.process}`); + if (session.attentionSince !== undefined) parts.push(`needs attention ${formatAge(now - session.attentionSince)}`); + else if (session.lastOutputAt !== undefined) { + const quietFor = now - session.lastOutputAt; + parts.push(quietFor < ACTIVE_OUTPUT_MS ? 'active' : `quiet ${formatAge(quietFor)}`); + } + if (session.fullscreen) parts.push('fullscreen'); + if (session.title) parts.push(`“${session.title.slice(0, 40)}”`); + return parts; +} diff --git a/src/session/sessionList.ts b/src/session/sessionList.ts index 5d9b8f3..c86152c 100644 --- a/src/session/sessionList.ts +++ b/src/session/sessionList.ts @@ -1,4 +1,11 @@ +import {homedir} from 'node:os'; import type {SessionInfo} from './SessionProtocol.js'; +import {liveStatusParts} from './liveStatus.js'; + +/** A path under the home directory as ~/…, so status stays visible in narrow rows. */ +export function tildePath(path: string, home = homedir()): string { + return home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path; +} export function formatAge(ms: number): string { const seconds = Math.max(0, Math.floor(ms / 1000)); @@ -20,5 +27,5 @@ export function formatBytes(bytes: number): string { export function formatSessionList(sessions: readonly SessionInfo[], now: number): string { if (sessions.length === 0) return 'No live NMSh sessions.\n'; return sessions.map(session => [session.id, session.state, `pid ${session.pid}`, `age ${formatAge(now - session.createdAt)}`, - session.cwd, ...(session.running ? [`running: ${session.running}`] : [])].join(' ')).join('\n') + '\n'; + session.cwd, liveStatusParts(session, now).join(' · ')].join(' ')).join('\n') + '\n'; } diff --git a/src/sessions/ResumeBrowser.ts b/src/sessions/ResumeBrowser.ts index 7fc6a8f..f6242de 100644 --- a/src/sessions/ResumeBrowser.ts +++ b/src/sessions/ResumeBrowser.ts @@ -1,6 +1,7 @@ import type {TranscriptSummary} from './TranscriptStore.js'; import type {SessionInfo} from '../session/SessionProtocol.js'; -import {formatAge} from '../session/sessionList.js'; +import {formatAge, tildePath} from '../session/sessionList.js'; +import {liveStatusParts} from '../session/liveStatus.js'; export interface ResumeBrowserState { /** Live service sessions other than this frontend's own; listed first. */ @@ -63,10 +64,7 @@ export function resumeRowCount(state: ResumeBrowserState): number { /** Facts only: where, how old, attached or not, and what zsh reports running. */ export function describeLiveSession(session: SessionInfo, now: number): string { const state = session.state === 'attached' ? 'attached in another window' : 'detached'; - const activity = session.running - ? `running ${session.running.replace(/\s+/gu, ' ').slice(0, 60)} · ${formatAge(now - (session.runningSince ?? now))}` - : `idle${session.idleSince ? ` ${formatAge(now - session.idleSince)}` : ''}`; - return `${session.cwd} · ${state} · ${activity} · started ${formatAge(now - session.createdAt)} ago`; + return [tildePath(session.cwd), state, ...liveStatusParts(session, now), `started ${formatAge(now - session.createdAt)} ago`].join(' · '); } export function visibleResumeSessions(state: ResumeBrowserState): TranscriptSummary[] { diff --git a/src/shell/ShellSession.ts b/src/shell/ShellSession.ts index 03b2c13..6f774cf 100644 --- a/src/shell/ShellSession.ts +++ b/src/shell/ShellSession.ts @@ -135,6 +135,11 @@ add-zsh-hook preexec nmsh_preexec return this.pty.pid; } + /** Name of the terminal's foreground process, read on demand; undefined if the platform cannot tell. */ + get foregroundProcess(): string | undefined { + try { return this.pty.process || undefined; } catch { return undefined; } + } + submit(command: string): void { this.pty.write(`${command}\r`); } diff --git a/tests/liveStatus.test.ts b/tests/liveStatus.test.ts new file mode 100644 index 0000000..5bf9d92 --- /dev/null +++ b/tests/liveStatus.test.ts @@ -0,0 +1,143 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {SessionEvidence} from '../src/session/SessionEvidence.js'; +import {commandWord, knownProgram, liveStatusParts} from '../src/session/liveStatus.js'; +import {decodeMessage, encodeMessage, type SessionInfo} from '../src/session/SessionProtocol.js'; +import {formatSessionList} from '../src/session/sessionList.js'; +import {describeLiveSession} from '../src/sessions/ResumeBrowser.js'; + +const NOW = 10_000_000; +const info = (extra: Partial = {}): SessionInfo => + ({id: 's1', pid: 42, state: 'detached', cwd: '/w/project', createdAt: NOW - 3_600_000, ...extra}); + +test('evidence: output recency, program title, and attention only while a command runs', () => { + const evidence = new SessionEvidence(); + evidence.observe('\u0007', 1); + assert.equal(evidence.snapshot().attentionSince, undefined, 'a bell at the prompt is not a program asking for attention'); + evidence.onExec(); + evidence.observe('hello', 100); + assert.equal(evidence.snapshot().lastOutputAt, 100); + // A title split across reads, terminated by BEL: the BEL ends the OSC and is not a bell. + evidence.observe('\u001b]0;Agent ✳ ', 200); + evidence.observe('working on tests\u0007', 210); + assert.equal(evidence.snapshot().title, 'Agent ✳ working on tests'); + assert.equal(evidence.snapshot().attentionSince, undefined); + evidence.observe('\u001b]9;4;1;50\u0007', 300); + assert.equal(evidence.snapshot().attentionSince, undefined, 'OSC 9;4 is a progress report, not a notification'); + evidence.observe('\u001b]2;bad\u0001title\u001b\\', 310); + assert.equal(evidence.snapshot().title, 'badtitle', 'ST-terminated; control characters are removed from titles'); + evidence.observe('\u001b]2;broken\u001b[31m red\r\n', 320); + assert.equal(evidence.snapshot().title, 'badtitle', 'a malformed OSC is not taken as a title'); + evidence.observe('\u001b]777;notify;Agent;Ready for input\u0007', 400); + assert.equal(evidence.snapshot().attentionSince, 400); + evidence.observe('\u0007', 500); + assert.equal(evidence.snapshot().attentionSince, 400, 'the first request is kept'); + evidence.onInput(); + assert.equal(evidence.snapshot().attentionSince, undefined, 'typing into the session clears it'); + evidence.observe('\u001b]9;Done\u0007', 600); + assert.equal(evidence.snapshot().attentionSince, 600, 'OSC 9 notification'); + evidence.observe('\u0007', 700); + evidence.onPrompt(3); + assert.deepEqual(evidence.snapshot(), {lastOutputAt: 700, lastExit: 3}, 'the command ended: title and attention reset'); + evidence.onExec(); + evidence.observe(`\u001b]0;${'x'.repeat(200)}\u0007`, 800); + assert.equal(evidence.snapshot().title!.length, 80, 'titles are bounded'); +}); + +test('status for ordinary and non-agent commands is factual and never names an agent', () => { + assert.deepEqual(liveStatusParts(info({idleSince: NOW - 180_000}), NOW), ['idle 3m']); + assert.deepEqual(liveStatusParts(info({idleSince: NOW - 5_000, lastExit: 0}), NOW), ['idle 5s', 'last command succeeded']); + assert.deepEqual(liveStatusParts(info({idleSince: NOW, lastExit: 2}), NOW), ['idle 0s', 'last command failed (exit 2)']); + const build = liveStatusParts(info({running: 'npm test', runningSince: NOW - 90_000, process: 'node', lastOutputAt: NOW - 2_000}), NOW); + assert.deepEqual(build, ['running npm test · 1m', 'process node', 'active']); + const quiet = liveStatusParts(info({running: 'make', runningSince: NOW - 600_000, process: 'make', lastOutputAt: NOW - 300_000}), NOW); + assert.deepEqual(quiet, ['running make · 10m', 'quiet 5m'], 'no process clause when it is the command itself'); + const vim = liveStatusParts(info({running: 'vim notes.md', runningSince: NOW - 60_000, process: 'vim', fullscreen: 1, + lastOutputAt: NOW - 30_000, title: 'notes.md (~/w) - VIM'}), NOW); + assert.deepEqual(vim, ['running vim notes.md · 1m', 'quiet 30s', 'fullscreen', '“notes.md (~/w) - VIM”']); + assert.deepEqual(liveStatusParts(info({running: 'sleep 99', runningSince: NOW}), NOW), ['running sleep 99 · 0s'], + 'no output evidence: nothing claimed'); +}); + +test('known CLIs are labelled from the command or the foreground process, with the same states as any program', () => { + assert.equal(commandWord('FOO=1 BAR=2 /opt/bin/claude --resume'), 'claude'); + assert.equal(knownProgram(info({running: 'claude'})), 'Claude Code'); + assert.equal(knownProgram(info({running: 'npx codex', process: 'codex'})), 'Codex'); + assert.equal(knownProgram(info({running: 'npm test', process: 'node'})), undefined); + const waiting = liveStatusParts(info({running: 'claude', runningSince: NOW - 600_000, process: 'node', fullscreen: 1, + lastOutputAt: NOW - 1_000, attentionSince: NOW - 120_000, title: '✳ Claude Code'}), NOW); + assert.deepEqual(waiting, ['running claude · 10m', 'Claude Code', 'needs attention 2m', 'fullscreen', '“✳ Claude Code”']); + const custom = liveStatusParts(info({running: 'my-agent --task x', runningSince: NOW, lastOutputAt: NOW, process: 'python3'}), NOW); + assert.deepEqual(custom, ['running my-agent --task x · 0s', 'process python3', 'active'], 'unrecognized agents still get evidence, just no name'); + for (const parts of [waiting, custom]) { + assert.ok(!parts.some(part => /thinking|approval|waiting for input|stuck/iu.test(part)), 'never claims intent'); + } +}); + +test('/resume rows and nmsh --list show the status; the new fields round-trip and are optional', () => { + const session = info({state: 'attached', running: 'codex', runningSince: NOW - 60_000, lastOutputAt: NOW - 20_000, attentionSince: NOW - 5_000}); + assert.equal(describeLiveSession(session, NOW), + '/w/project · attached in another window · running codex · 1m · Codex · needs attention 5s · started 1h 0m ago'); + assert.match(formatSessionList([session], NOW), /s1 {2}attached {2}pid 42 {2}age 1h 0m {2}\/w\/project {2}running codex · 1m · Codex · needs attention 5s/); + const full: SessionInfo = {...session, process: 'node', fullscreen: 1, title: 't', lastExit: 1}; + const message = {type: 'sessions' as const, sessions: [full, info()]}; + const decoded = decodeMessage(encodeMessage(message).trimEnd()); + assert.deepEqual(decoded, {ok: true, message}); + const legacy = decodeMessage(JSON.stringify({v: 2, type: 'sessions', sessions: [{id: 'old', pid: 1, state: 'detached', cwd: '/', createdAt: 1}]})); + assert.equal(legacy.ok, true, 'an older service without evidence still decodes'); +}); + +test('end to end: a CLI’s own title and notification reach /resume across detach, and typing after reattach clears attention', async () => { + const {LiveSandbox, until} = await import('./helpers/liveFrontend.js'); + const {mkdirSync, writeFileSync, chmodSync} = await import('node:fs'); + const {join} = await import('node:path'); + const sandbox = new LiveSandbox(); + const bin = join(sandbox.home, 'bin'); + mkdirSync(bin); + // Named like a known agent; it only prints, sets a title, notifies, and reads a line. + writeFileSync(join(bin, 'claude'), [ + '#!/bin/sh', + "printf '\\033]0;Fake agent: ready\\007'", + "printf 'FAKE-AGENT-UP\\n'", + "printf '\\033]777;notify;Fake agent;Ready\\007'", + 'read line', + 'echo "GOT-$line"', + ].join('\n')); + chmodSync(join(bin, 'claude'), 0o755); + try { + const a = sandbox.launch(); + await a.waitFor(/❯/); + await a.run(`export PATH="${bin}:$PATH"`, /❯/); + await a.run('claude', /FAKE-AGENT-UP/); + await until(async () => (await sandbox.sessions())[0]?.attentionSince !== undefined, 15000, 'attention recorded'); + const [live] = await sandbox.sessions(); + assert.equal(live!.running?.includes('claude'), true); + assert.equal(live!.title, 'Fake agent: ready'); + assert.equal(knownProgram(live!), 'Claude Code'); + a.pty.kill('SIGKILL'); + await until(async () => (await sandbox.sessions())[0]?.state === 'detached', 15000, 'detached'); + assert.notEqual((await sandbox.sessions())[0]!.attentionSince, undefined, 'survives the detach'); + + const viewer = sandbox.launch(['--new']); + await viewer.waitFor(/❯/); + await viewer.run('sleep 30 &', /❯/); + const mark = viewer.mark; + viewer.pty.write('/resume\r'); + await viewer.waitFor(/Claude Code · needs attention[\s\S]*Fake agent: ready/, mark); + viewer.pty.write('\u001b'); + + const b = sandbox.launch(['--attach', live!.id]); + // A known interactive CLI reattaches straight into passthrough, so wait on the service, not NMSh's UI. + await until(async () => (await sandbox.sessions()).find(candidate => candidate.id === live!.id)?.state === 'attached', 15000, 'reattached'); + // Entering passthrough turns NMSh's own bracketed paste off; only then does typing reach the program. + await until(() => b.output.includes('\u001b[?2004l'), 15000, 'passthrough'); + b.pty.write('hello\r'); + await b.waitFor(/GOT-hello/); + await until(async () => { + const session = (await sandbox.sessions()).find(candidate => candidate.id === live!.id); + return session !== undefined && session.running === undefined && session.lastExit === 0 && session.attentionSince === undefined; + }, 15000, 'command ended; attention cleared; last exit recorded'); + } finally { + await sandbox.dispose(); + } +});