From 7ee40e72cff6ca1b2012723a80e92b55b772fce9 Mon Sep 17 00:00:00 2001 From: J Date: Thu, 15 Oct 2026 14:29:31 +0530 Subject: [PATCH 01/33] feat(track): add live command tracking slash alias and controls --- src/commands/slashCommands.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/commands/slashCommands.ts b/src/commands/slashCommands.ts index 5a9aca71..5acb5df1 100644 --- a/src/commands/slashCommands.ts +++ b/src/commands/slashCommands.ts @@ -30,6 +30,7 @@ const META: Record> = { '/tools': {group: 'Tools & integration', title: 'Open Tools'}, '/configure': {group: 'Tools & integration', title: 'Open Tool Configuration'}, '/tmux': {group: 'Tools & integration', title: 'Configure tmux'}, '/integrations': {group: 'Tools & integration', title: 'Check Integrations'}, '/dotfiles': {group: 'Tools & integration', title: 'Import Dotfiles'}, + '/track': {group: 'Tools & integration', title: 'Track live command output'}, '/btw': {title: 'Open local intelligence'}, '/ask': {alias: '/btw'}, '/caffeinate': {group: 'Tools & integration', title: 'Open Keep Awake'}, '/awake': {alias: '/caffeinate'}, '/zoomies': {alias: '/caffeinate'}, }; @@ -115,6 +116,7 @@ const RAW_COMMANDS: readonly SlashCommand[] = [ {name: '/rename', insertion: '/rename ', description: 'Name this live session (display only); /rename alone returns to its familiar signature'}, {name: '/queue', insertion: '/queue', description: 'Commands queued to run after the current one: view, edit, reorder, pause or clear (/queue pause|resume|clear)'}, {name: '/watch', insertion: '/watch ', description: 'Run a command repeatedly and show what changed (/watch git status · --every 10s · /watch stop|pause|resume)'}, + {name: '/track', insertion: '/track ', description: 'Refresh a finite command in one live view (/track df -h /Volumes/T7Projects · /track --every 5s -- docker system df · pause|resume|refresh|stop)'}, {name: '/doctor', insertion: '/doctor', description: 'Health check: NMSh, shell, project, Git, tools, local model and host (local, read-only)'}, {name: '/llm', insertion: '/llm', description: 'Local Intelligence: the optional local model for Ask and Smart Folding (status, setup, stop, remove)'}, {name: '/providers', insertion: '/providers', description: 'What NMSh uses for prompt, welcome, suggestions, history and more; switch, install, detect'}, @@ -351,11 +353,14 @@ export function parseSlashCommand(input: string): ParsedSlashCommand | undefined if (/^\/doctor\s*$/u.test(input)) return {kind: 'doctor'}; const queue = /^\/queue(?:\s+(pause|resume|clear))?\s*$/u.exec(input); if (queue) return {kind: 'queue', op: (queue[1] ?? 'open') as 'open' | 'pause' | 'resume' | 'clear'}; - const watch = /^\/watch(?:\s+(.*))?$/u.exec(input); + // /track reuses the established /watch scheduler, safety gate and live panel. + // Refresh is a spelling of 'now'; it never makes an overlapping run. + const watch = /^\/(?:watch|track)(?:\s+(.*))?$/u.exec(input); if (watch) { const rest = (watch[1] ?? '').trim(); - const op = /^(stop|pause|resume|now)(?:\s+(.*))?$/u.exec(rest); - return op ? {kind: 'watch', op: op[1] as 'stop' | 'pause' | 'resume' | 'now', arguments: (op[2] ?? '').trim()} : {kind: 'watch', op: rest ? 'start' : 'list', arguments: rest}; + const op = /^(stop|pause|resume|now|refresh)(?:\s+(.*))?$/u.exec(rest); + return op ? {kind: 'watch', op: op[1] === 'refresh' ? 'now' : op[1] as 'stop' | 'pause' | 'resume' | 'now', arguments: (op[2] ?? '').trim()} + : {kind: 'watch', op: rest && rest !== 'list' ? 'start' : 'list', arguments: rest === 'list' ? '' : rest}; } const rename = /^\/rename(?:\s+(.*))?$/u.exec(input); if (rename) return {kind: 'rename', name: (rename[1] ?? '').trim()}; From 4b81394062cb021b25524243ebcb98ce99ccf60c Mon Sep 17 00:00:00 2001 From: J Date: Thu, 15 Oct 2026 14:29:59 +0530 Subject: [PATCH 02/33] feat(track): accept disk reports and preserve safe finite snapshots --- src/tasks/WatchTasks.ts | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/tasks/WatchTasks.ts b/src/tasks/WatchTasks.ts index d051bab1..039c699b 100644 --- a/src/tasks/WatchTasks.ts +++ b/src/tasks/WatchTasks.ts @@ -1,5 +1,6 @@ import {spawn, type ChildProcess} from 'node:child_process'; import {classifyCommand, splitCommands, type PasteKind} from '../input/pasteGuard.js'; +import {stripTerminalControls} from '../util/terminalControls.js'; /** * /watch: NMSh runs a command again and again on its own schedule (never a @@ -36,19 +37,24 @@ export function watchSafety(command: string): WatchSafety { const target = /https?:\/\/([^/:\s]+)/u.exec(part.text)?.[1] ?? /\s([\w.-]+\.[a-z]{2,})(?:\s|$)/iu.exec(part.text)?.[1]; if (target && !/^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[?::1\]?)$/u.test(target)) remote = true; } - if ((kinds.includes('unknown') || kinds.includes('project') || kinds.includes('text')) && !TEST_RUNNERS.test(part.text) && !/^git\s+(?:status|log|diff|branch|remote|show)\b/u.test(part.text)) unknown = true; + // Allow only Docker's explicit read-only disk report. Other Docker verbs (including + // 'system prune') still require confirmation, or are refused by the classifier. + const dockerDiskReport = /^docker\s+system\s+df(?:\s+(?:-v|--verbose))?\s*$/u.test(part.text); + if ((kinds.includes('unknown') || kinds.includes('project') || kinds.includes('text')) && !dockerDiskReport && !TEST_RUNNERS.test(part.text) && !/^git\s+(?:status|log|diff|branch|remote|show)\b/u.test(part.text)) unknown = true; } return unknown ? {kind: 'confirm', reason: 'NMSh can\'t tell whether this command changes anything; it will run every interval.', network, remote} : {kind: 'allowed', network, remote}; } /** "--every 5s", "every 10 seconds", "-n 2" → milliseconds (clamped to the minimum for the command). */ export function parseWatch(text: string): {command: string; intervalMs?: number} { - const every = /^(?:--every|-n|every)\s+(\d+(?:\.\d+)?)\s*(s|sec|secs|seconds?|m|min|minutes?|ms)?\s+/u.exec(text.trim()); - if (!every) return {command: text.trim()}; + const input = text.trim(); + const every = /^(?:--every|-n|every)\s+(\d+(?:\.\d+)?)\s*(s|sec|secs|seconds?|m|min|minutes?|ms)?(?:\s+|$)/u.exec(input); + const command = (every ? input.slice(every[0].length) : input).replace(/^--(?:\s+|$)/u, '').trim(); + if (!every) return {command}; const value = Number(every[1]); const unit = every[2] ?? 's'; const intervalMs = unit === 'ms' ? value : /^m/u.test(unit) && unit !== 'ms' ? value * 60_000 : value * 1000; - return {command: text.trim().slice(every[0].length).trim(), intervalMs}; + return {command, intervalMs}; } export interface WatchRun {output: string; exitCode: number | null; at: number; durationMs: number} @@ -134,6 +140,9 @@ export class WatchTasks { runNow(id: string): void { const watch = this.get(id); if (!watch || watch.status === 'stopped' || this.children.has(id)) return; + const timer = this.timers.get(id); + if (timer) clearTimeout(timer); + this.timers.delete(id); const started = this.now(); let output = ''; let child: ChildProcess; @@ -151,10 +160,14 @@ export class WatchTasks { clearTimeout(limit); if (this.children.get(id) !== child) return; this.children.delete(id); - this.record(watch, {output: output.replace(/\u001b\[[0-?]*[ -/]*[@-~]/gu, ''), exitCode: code, at: started, durationMs: this.now() - started}); + // Each result is a fresh snapshot; hostile ANSI/OSC/bidi output must not + // escape the owned panel into terminal control or fake NMSh chrome. + this.record(watch, {output: output.split('\n').map(line => stripTerminalControls(line, MAX_OUTPUT)).join('\n'), + exitCode: code, at: started, durationMs: this.now() - started}); }; child.on('error', () => done(-1)); - child.on('exit', code => done(code)); + // Wait for the stdio streams to close before freezing the snapshot. + child.on('close', code => done(code)); } private record(watch: WatchTask, run: WatchRun): void { From 55119c0380a331309161dcc94c7f3a4c8391c67c Mon Sep 17 00:00:00 2001 From: J Date: Thu, 15 Oct 2026 14:30:56 +0530 Subject: [PATCH 03/33] feat(track): show current snapshot freshness in live command view --- src/tasks/WatchPanel.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/tasks/WatchPanel.ts b/src/tasks/WatchPanel.ts index 2fddd282..7ffe4050 100644 --- a/src/tasks/WatchPanel.ts +++ b/src/tasks/WatchPanel.ts @@ -5,7 +5,7 @@ import {foreground, UI_COLORS} from '../ui/palette.js'; import {truncateAnsi} from '../util/text.js'; import type {WatchTask} from './WatchTasks.js'; -/** /watch: one live block per watch (current, previous, what changed), never a pile of repeated output. */ +/** /track and /watch share one live snapshot view: no duplicated transcript output. */ export interface WatchPanelState { selected: number; /** Showing the selected watch's current output. */ @@ -49,8 +49,9 @@ export function watchRow(watch: WatchTask, now: number): string { const reset = '\u001b[0m'; const state = watch.current ? (watch.current.exitCode === 0 ? foreground(UI_COLORS.success) : foreground(UI_COLORS.failure)) : subtle; const mark = watch.status === 'paused' ? '‖' : '◉'; - return `${state}${mark}${reset} ${secondary}Watching · ${watch.command.slice(0, 40)}${reset}${subtle} · every ${formatEvery(watch.intervalMs)}${watch.summary ? ` · ${watch.summary}` : ''}` - + `${watch.changes.length ? ' · changed' : ''} · runs ${watch.runs}${watch.status === 'paused' ? ' · paused' : ` · ${ago(now - watch.startedAt)}`}${reset}`; + const last = watch.current ? ` · updated ${ago(Math.max(0, now - watch.current.at - watch.current.durationMs))} ago` : ' · first run pending'; + return `${state}${mark}${reset} ${secondary}Track · ${watch.command.slice(0, 40)}${reset}${subtle} · every ${formatEvery(watch.intervalMs)}${watch.summary ? ` · ${watch.summary}` : ''}` + + `${watch.changes.length ? ' · changed' : ''} · runs ${watch.runs}${watch.status === 'paused' ? ' · paused' : last}${reset}`; } export function renderWatchPanel(state: WatchPanelState, watches: readonly WatchTask[], columns: number, now: number, rowsAvailable: number): string[] { @@ -59,7 +60,7 @@ export function renderWatchPanel(state: WatchPanelState, watches: readonly Watch const subtle = foreground(UI_COLORS.subtle); const accent = foreground(UI_COLORS.accent); const reset = '\u001b[0m'; - const rows = [`${primary} Watch${reset}`]; + const rows = [`${primary} Live commands · /track / /watch${reset}`]; if (state.confirm) { const yes = state.confirm.choice === 'yes'; rows.push('', ` ${primary}Watch ${state.confirm.command}?${reset}`, ` ${secondary}${state.confirm.reason}${reset}`, @@ -69,7 +70,7 @@ export function renderWatchPanel(state: WatchPanelState, watches: readonly Watch return rows.map(row => truncateAnsi(row, columns)); } if (!watches.length) { - rows.push('', ` ${secondary}Nothing is being watched.${reset}`, ` ${subtle}/watch git status · /watch --every 10s npm test · stop with /watch stop${reset}`); + rows.push('', ` ${secondary}No active command trackers.${reset}`, ` ${subtle}/track df -h /Volumes/T7Projects · /track --every 5s -- docker system df${reset}`); if (state.message) rows.push('', ` ${secondary}${state.message}${reset}`); rows.push('', renderControls([['Esc', 'close']])); return rows.map(row => truncateAnsi(row, columns)); @@ -80,7 +81,7 @@ export function renderWatchPanel(state: WatchPanelState, watches: readonly Watch if (state.output) { const lines = (watch.current?.output ?? '').split('\n'); const room = Math.max(4, rowsAvailable - rows.length - 3); - rows.push(` ${subtle}Current output · ${lines.length} lines${lines.length > room ? ` (last ${room})` : ''}${reset}`, ...lines.slice(-room).map(line => ` ${line}`)); + rows.push(` ${subtle}Latest completed output · ${lines.length} lines${lines.length > room ? ` (last ${room})` : ''}${watch.current ? ` · exit ${watch.current.exitCode ?? 'signal'}` : ' · pending'}${reset}`, ...lines.slice(-room).map(line => ` ${line}`)); } else { rows.push(` ${subtle}Current${reset} ${secondary}${watch.summary ?? (watch.current ? `exit ${watch.current.exitCode}` : 'running…')}${reset}`); if (watch.previous) rows.push(` ${subtle}Previous${reset} ${secondary}${watch.previousSummary ?? `exit ${watch.previous.exitCode}`}${reset}`); From fd05602a5413e4ebed3e8de53d604c34a93b4184 Mon Sep 17 00:00:00 2001 From: J Date: Thu, 15 Oct 2026 14:31:33 +0530 Subject: [PATCH 04/33] test(track): exercise aliases, safety, snapshots and control filtering --- tests/track.test.ts | 102 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 tests/track.test.ts diff --git a/tests/track.test.ts b/tests/track.test.ts new file mode 100644 index 00000000..0e98e290 --- /dev/null +++ b/tests/track.test.ts @@ -0,0 +1,102 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {parseSlashCommand} from '../src/commands/slashCommands.js'; +import {parseWatch, watchSafety, WatchTasks, MIN_LOCAL_MS} from '../src/tasks/WatchTasks.js'; +import {renderWatchPanel, watchRow} from '../src/tasks/WatchPanel.js'; +import {stripAnsi} from '../src/util/text.js'; + +function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise { + return new Promise((resolve, reject) => { + const started = Date.now(); + const tick = () => { + if (predicate()) { resolve(); return; } + if (Date.now() - started > timeoutMs) { reject(new Error('track did not reach expected state')); return; } + setTimeout(tick, 20); + }; + tick(); + }); +} + +test('/track is a native /watch alias with list, refresh and controls', () => { + assert.deepEqual(parseSlashCommand('/track df -h /Volumes/T7Projects'), + {kind: 'watch', op: 'start', arguments: 'df -h /Volumes/T7Projects'}); + assert.deepEqual(parseSlashCommand('/track --every 5s -- docker system df'), + {kind: 'watch', op: 'start', arguments: '--every 5s -- docker system df'}); + for (const text of ['/track', '/track list']) { + assert.deepEqual(parseSlashCommand(text), {kind: 'watch', op: 'list', arguments: ''}); + } + assert.deepEqual(parseSlashCommand('/track refresh 2'), {kind: 'watch', op: 'now', arguments: '2'}); + assert.deepEqual(parseSlashCommand('/track pause'), {kind: 'watch', op: 'pause', arguments: ''}); + assert.deepEqual(parseSlashCommand('/track resume 1'), {kind: 'watch', op: 'resume', arguments: '1'}); + assert.deepEqual(parseSlashCommand('/track stop all'), {kind: 'watch', op: 'stop', arguments: 'all'}); + assert.deepEqual(parseSlashCommand('/watch stop 2'), {kind: 'watch', op: 'stop', arguments: '2'}, + 'existing /watch commands must remain compatible'); + assert.deepEqual(parseWatch('df -h /Volumes/T7Projects'), + {command: 'df -h /Volumes/T7Projects'}); + assert.deepEqual(parseWatch('--every 5s -- docker system df'), + {command: 'docker system df', intervalMs: 5000}); + assert.deepEqual(parseWatch('--every 2s -- df -h /Volumes/T7Projects'), + {command: 'df -h /Volumes/T7Projects', intervalMs: MIN_LOCAL_MS}); +}); + +test('/track classifies disk information as read-only, but never assumes Docker writes are safe', () => { + assert.equal(watchSafety('df -h /Volumes/T7Projects').kind, 'allowed'); + assert.equal(watchSafety('docker system df').kind, 'allowed'); + assert.equal(watchSafety('docker system df -v').kind, 'allowed'); + assert.equal(watchSafety('docker system prune').kind, 'confirm'); + assert.equal(watchSafety('docker system df; rm -rf /tmp/x').kind, 'refused'); + assert.equal(watchSafety('docker system df && docker system prune').kind, 'confirm'); + assert.equal(watchSafety('sudo docker system df').kind, 'refused'); + assert.equal(watchSafety('docker system df $(touch /tmp/unsafe)').kind, 'confirm'); +}); + +test('/track reuses one current and previous snapshot, does not overlap manual refresh', async () => { + const cwd = mkdtempSync(join(tmpdir(), 'nmsh-track-')); + const engine = new WatchTasks('/bin/sh'); + try { + const task = engine.start('sleep 0.3; printf "sample\\n"', cwd, 2000, + {kind: 'allowed', network: false, remote: false}); + engine.runNow(task.id); + engine.runNow(task.id); + await waitFor(() => task.runs === 1); + assert.equal(task.runs, 1, 'manual refresh must not overlap a running sample'); + assert.equal(task.current?.output, 'sample\n'); + assert.equal(task.previous, undefined, 'no artificial previous sample'); + assert.equal(engine.active().length, 1); + assert.match(stripAnsi(watchRow(task, Date.now())), /Track · sleep 0\.3.*updated/u); + assert.match(stripAnsi(renderWatchPanel({selected: 0, output: true}, [task], 100, Date.now(), 20).join('\n')), + /Latest completed output.*sample/u); + engine.runNow(task.id); + await waitFor(() => task.runs === 2); + assert.equal(task.current?.output, 'sample\n'); + assert.equal(task.previous?.output, 'sample\n'); + assert.equal(task.changes.length, 0); + assert.ok(engine.pause(task.id)); + assert.equal(engine.scheduled, 0); + assert.ok(engine.resume(task.id)); + assert.ok(engine.stop(task.id)); + assert.equal(engine.active().length, 0); + } finally { + engine.dispose(); + rmSync(cwd, {recursive: true, force: true}); + } +}); + +test('/track strips terminal control sequences from the displayed snapshot', async () => { + const cwd = mkdtempSync(join(tmpdir(), 'nmsh-track-controls-')); + const engine = new WatchTasks('/bin/sh'); + try { + const task = engine.start('printf "\\033]0;bad-title\\007alpha\\n\\033[31mbeta\\033[0m\\n"', cwd, 5000, + {kind: 'allowed', network: false, remote: false}); + await waitFor(() => task.runs === 1); + assert.equal(task.current?.exitCode, 0); + assert.equal(task.current?.output, 'alpha\nbeta\n'); + assert.ok(engine.stop(task.id)); + } finally { + engine.dispose(); + rmSync(cwd, {recursive: true, force: true}); + } +}); From e1d0c160775ed23784a08f79a10536baa962dd19 Mon Sep 17 00:00:00 2001 From: J Date: Thu, 15 Oct 2026 14:31:54 +0530 Subject: [PATCH 05/33] test(track): match multiline snapshot view --- tests/track.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/track.test.ts b/tests/track.test.ts index 0e98e290..7587baa1 100644 --- a/tests/track.test.ts +++ b/tests/track.test.ts @@ -68,7 +68,7 @@ test('/track reuses one current and previous snapshot, does not overlap manual r assert.equal(engine.active().length, 1); assert.match(stripAnsi(watchRow(task, Date.now())), /Track · sleep 0\.3.*updated/u); assert.match(stripAnsi(renderWatchPanel({selected: 0, output: true}, [task], 100, Date.now(), 20).join('\n')), - /Latest completed output.*sample/u); + /Latest completed output[\s\S]*sample/u); engine.runNow(task.id); await waitFor(() => task.runs === 2); assert.equal(task.current?.output, 'sample\n'); From 8ab7bc2a0566bbff3a23f7e58c2063a8959e1d37 Mon Sep 17 00:00:00 2001 From: J Date: Thu, 15 Oct 2026 14:33:21 +0530 Subject: [PATCH 06/33] feat(track): offer explicit current-snapshot copy in live view --- src/tasks/WatchPanel.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/tasks/WatchPanel.ts b/src/tasks/WatchPanel.ts index 7ffe4050..b41cdc77 100644 --- a/src/tasks/WatchPanel.ts +++ b/src/tasks/WatchPanel.ts @@ -15,7 +15,7 @@ export interface WatchPanelState { message?: string; } -export type WatchPanelAction = {kind: 'close'} | {kind: 'pause' | 'resume' | 'stop' | 'now'; id: string} | {kind: 'confirm'}; +export type WatchPanelAction = {kind: 'close'} | {kind: 'pause' | 'resume' | 'stop' | 'now' | 'copy'; id: string} | {kind: 'confirm'}; const formatEvery = (ms: number) => ms % 60_000 === 0 ? `${ms / 60_000}m` : `${Math.round(ms / 100) / 10}s`; const ago = (ms: number) => ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`; @@ -37,6 +37,7 @@ export function watchPanelKey(state: WatchPanelState, key: Key, watches: readonl const letter = key.value.toLowerCase(); if (letter === 'p') return {kind: watch.status === 'paused' ? 'resume' : 'pause', id: watch.id}; if (letter === 'r') return {kind: 'now', id: watch.id}; + if (letter === 'c') return {kind: 'copy', id: watch.id}; if (letter === 's') return {kind: 'stop', id: watch.id}; } return undefined; @@ -89,6 +90,6 @@ export function renderWatchPanel(state: WatchPanelState, watches: readonly Watch rows.push(` ${subtle}Runs ${watch.runs} · ${ago(now - watch.startedAt)}${watch.network ? ' · repeated network access' : ''}${reset}`); } if (state.message) rows.push('', ` ${secondary}${state.message}${reset}`); - rows.push('', renderControls([['↑↓', 'select'], ['Enter', state.output ? 'summary' : 'view output'], ['P', watch.status === 'paused' ? 'resume' : 'pause'], ['R', 'run now'], ['S', 'stop'], ['Esc', 'close']])); + rows.push('', renderControls([['↑↓', 'select'], ['Enter', state.output ? 'summary' : 'view output'], ['P', watch.status === 'paused' ? 'resume' : 'pause'], ['R', 'run now'], ['C', 'copy latest'], ['S', 'stop'], ['Esc', 'close']])); return rows.map(row => truncateAnsi(row, columns)); } From 8f5ff7aefdfb89cb36741b887190821a083a7584 Mon Sep 17 00:00:00 2001 From: J Date: Thu, 15 Oct 2026 14:33:42 +0530 Subject: [PATCH 07/33] feat(track): copy only the most recent completed snapshot --- src/app/TerminalApp.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index a6175622..aa351dc6 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -1784,6 +1784,19 @@ export class TerminalApp { } else if (action?.kind === 'pause') this.watches.pause(action.id); else if (action?.kind === 'resume') this.watches.resume(action.id); else if (action?.kind === 'now') this.watches.runNow(action.id); + else if (action?.kind === 'copy') { + const latest = this.watches.get(action.id)?.current; + if (!latest) panel.message = 'No completed output to copy yet.'; + else { + // The owned display snapshot is explicitly copied, never the accumulated + // stream from earlier refreshes. The normal /copy policy is unchanged. + void writeClipboard(latest.output).then(() => { + if (this.watchPanel === panel && !this.stopped) { panel.message = 'Copied latest tracked output.'; this.render(); } + }, () => { + if (this.watchPanel === panel && !this.stopped) { panel.message = 'Clipboard unavailable; nothing confirmed copied.'; this.render(); } + }); + } + } else if (action?.kind === 'stop') this.watches.stop(action.id); this.render(); return; From 10f2cf430e503f69ac3b97049c59a7b4b31be7f1 Mon Sep 17 00:00:00 2001 From: J Date: Thu, 15 Oct 2026 14:33:59 +0530 Subject: [PATCH 08/33] test(track): verify snapshot copy action targets selected tracker --- tests/track.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/track.test.ts b/tests/track.test.ts index 7587baa1..606811c2 100644 --- a/tests/track.test.ts +++ b/tests/track.test.ts @@ -5,7 +5,7 @@ import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {parseSlashCommand} from '../src/commands/slashCommands.js'; import {parseWatch, watchSafety, WatchTasks, MIN_LOCAL_MS} from '../src/tasks/WatchTasks.js'; -import {renderWatchPanel, watchRow} from '../src/tasks/WatchPanel.js'; +import {renderWatchPanel, watchPanelKey, watchRow} from '../src/tasks/WatchPanel.js'; import {stripAnsi} from '../src/util/text.js'; function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise { @@ -67,6 +67,8 @@ test('/track reuses one current and previous snapshot, does not overlap manual r assert.equal(task.previous, undefined, 'no artificial previous sample'); assert.equal(engine.active().length, 1); assert.match(stripAnsi(watchRow(task, Date.now())), /Track · sleep 0\.3.*updated/u); + assert.deepEqual(watchPanelKey({selected: 0, output: true}, {kind: 'text', value: 'c'}, [task]), + {kind: 'copy', id: task.id}, 'copy targets exactly the selected tracker'); assert.match(stripAnsi(renderWatchPanel({selected: 0, output: true}, [task], 100, Date.now(), 20).join('\n')), /Latest completed output[\s\S]*sample/u); engine.runNow(task.id); From 0cbcb11a40f1bb0663e31b9d967c5adaff9e38ce Mon Sep 17 00:00:00 2001 From: J Date: Thu, 15 Oct 2026 14:34:30 +0530 Subject: [PATCH 09/33] docs(track): document live tracker behavior and remaining service scope --- .../development/track-live-command-refresh.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 docs/development/track-live-command-refresh.md diff --git a/docs/development/track-live-command-refresh.md b/docs/development/track-live-command-refresh.md new file mode 100644 index 00000000..6666df0e --- /dev/null +++ b/docs/development/track-live-command-refresh.md @@ -0,0 +1,36 @@ +# Live command tracking (`/track`) + +Tracking is a native command over the existing bounded `/watch` scheduler and display. It periodically executes a **finite** command outside the foreground PTY and replaces the latest completed **display snapshot** in one persistent live command view. It does not append a foreground transcript entry for each refresh. + +## Usage + +```sh +/track df -h /Volumes/T7Projects +/track docker system df +/track --every 5s -- docker system df +/track list +/track refresh +/track pause 1 +/track resume 1 +/track stop 1 +/track stop all +``` + +The `--` delimiter after the cadence is optional, but preferred when arguments are ambiguous. `/watch` continues to accept its existing syntax, and both names manage the **same** tracked commands. A bare `/track` opens the shared manager. Indexes in controls are current *active* positions; use the panel for selection. + +The compact track row above the composer reports the command, cadence, latest completion freshness, state and run count. Open the panel to see current output, previous summary and detected changes; **Enter** toggles full current output, **P** pauses/resumes, **R** refreshes once, **C** copies the latest completed sanitized snapshot, **S** stops. A still-running sample never replaces a completed sample early. Errors and exit codes remain visible. + +## Command execution and safety + +- Every sample starts a separate finite `/bin/sh -c` process with its own process group and the working directory captured on creation. It does **not** run inside or inherit aliases/functions/unexported variables from the persistent interactive zsh/Bash/Fish shell. +- Default interval: five seconds; minimum two seconds locally and 15 seconds for remote network commands. A sample never overlaps another sample of the same tracker. Manual refresh cancels its pending timer, but never interrupts a sample already executing. +- Repeated commands may have side effects. A read-only `df` or exact `docker system df` report can start immediately. Unknown commands require explicit confirmation; classified destructive/privileged/modifying commands are refused. Other Docker operations are **not** automatically trusted. +- Each run has bounded output and an execution timeout. Full terminal control sequences are removed from the **displayed** text. Copying from the track panel copies this sanitized latest display snapshot, *not* raw PTY bytes or all prior refreshes. It is explicit and unredacted, like plain `/copy`; do not copy secrets casually. + +## Scope and outstanding design + +This initial delivery reuses the currently **window-scoped** `/watch` implementation: trackers stop when this NMSh frontend exits, rather than surviving detach as #382 service-owned tasks. The current UI is a compact live row plus a detail panel, **not an editable transcript command block**. It does not yet implement persistent/restorable session-service scheduler identity, deep historical snapshot search, full raw-byte clipboard parity, or durable one-block transcript integration. + +Those remain tracked on [#393](https://github.com/raiseCatError/notMyShell/issues/393). Do not claim full #393 acceptance or physical terminal validation from automated tests. Any service integration should reuse #382's supervision and preserve foreground input/queue ownership, rather than introducing a second task service. + +This branch is a **post-v0.20 draft feature**. It does not expand v0.19 or v0.20 release gates and should not be merged into either release candidate without a separate maintainer decision. From dbf5d9b9c51a679e7abcaa7274e0bd9f2aeb986a Mon Sep 17 00:00:00 2001 From: J Date: Thu, 15 Oct 2026 14:56:11 +0530 Subject: [PATCH 10/33] fix(track): register Ask command --- src/ask/concepts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ask/concepts.ts b/src/ask/concepts.ts index a2fce2fe..b06f608e 100644 --- a/src/ask/concepts.ts +++ b/src/ask/concepts.ts @@ -158,7 +158,7 @@ export const CONCEPTS: readonly Concept[] = [ {id: 'doctor', label: 'Doctor', support: 'actionable', open: '/doctor', covers: ['/doctor'], aliases: ['doctor', 'health check', 'diagnostics', 'diagnose', 'check my setup', 'is everything ok', 'whats wrong with my setup', 'check environment'], description: '/doctor checks NMSh, the shell, this project, Git, tools, the local model and the host terminal with local, read-only checks, and offers actions that open the right place; it never fixes anything silently.'}, - {id: 'watch', label: 'Watch', support: 'actionable', open: '/watch', covers: ['/watch'], + {id: 'watch', label: 'Watch', support: 'actionable', open: '/watch', covers: ['/watch', '/track'], aliases: ['watch', 'watch a command', 'rerun every', 'repeat a command', 'keep running', 'monitor a command'], description: '/watch runs a command on NMSh\'s own schedule and shows what changed in one live block (pause, run now, stop). Commands that install, modify or destroy are refused; unknown ones need a Yes.'}, {id: 'pastePreview', label: 'Paste preview', support: 'settings', open: '/settings', where: 'Settings → Editor → Paste preview (Smart, Always, Off)', covers: ['settings:Editor'], From f275dbfdde7d05e067823dee02d93727afd3ed6d Mon Sep 17 00:00:00 2001 From: J Date: Thu, 15 Oct 2026 14:56:15 +0530 Subject: [PATCH 11/33] fix(track): preserve palette ranking --- src/commands/slashCommands.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/slashCommands.ts b/src/commands/slashCommands.ts index 5acb5df1..f92d7609 100644 --- a/src/commands/slashCommands.ts +++ b/src/commands/slashCommands.ts @@ -30,7 +30,7 @@ const META: Record> = { '/tools': {group: 'Tools & integration', title: 'Open Tools'}, '/configure': {group: 'Tools & integration', title: 'Open Tool Configuration'}, '/tmux': {group: 'Tools & integration', title: 'Configure tmux'}, '/integrations': {group: 'Tools & integration', title: 'Check Integrations'}, '/dotfiles': {group: 'Tools & integration', title: 'Import Dotfiles'}, - '/track': {group: 'Tools & integration', title: 'Track live command output'}, + '/track': {alias: '/watch'}, '/btw': {title: 'Open local intelligence'}, '/ask': {alias: '/btw'}, '/caffeinate': {group: 'Tools & integration', title: 'Open Keep Awake'}, '/awake': {alias: '/caffeinate'}, '/zoomies': {alias: '/caffeinate'}, }; From f426f59d56f5af8863ff1c9194b03a83944b69d2 Mon Sep 17 00:00:00 2001 From: J Date: Thu, 15 Oct 2026 14:56:19 +0530 Subject: [PATCH 12/33] fix(track): improve help --- src/commands/slashCommands.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/slashCommands.ts b/src/commands/slashCommands.ts index f92d7609..681e8ef3 100644 --- a/src/commands/slashCommands.ts +++ b/src/commands/slashCommands.ts @@ -115,7 +115,7 @@ const RAW_COMMANDS: readonly SlashCommand[] = [ {name: '/guide', insertion: '/guide', description: 'Interactive guide to everything NMSh can do'}, {name: '/rename', insertion: '/rename ', description: 'Name this live session (display only); /rename alone returns to its familiar signature'}, {name: '/queue', insertion: '/queue', description: 'Commands queued to run after the current one: view, edit, reorder, pause or clear (/queue pause|resume|clear)'}, - {name: '/watch', insertion: '/watch ', description: 'Run a command repeatedly and show what changed (/watch git status · --every 10s · /watch stop|pause|resume)'}, + {name: '/watch', insertion: '/watch ', description: 'Repeat finite commands with one live result (/watch or /track git status · --every 10s · pause|resume|refresh|stop)'}, {name: '/track', insertion: '/track ', description: 'Refresh a finite command in one live view (/track df -h /Volumes/T7Projects · /track --every 5s -- docker system df · pause|resume|refresh|stop)'}, {name: '/doctor', insertion: '/doctor', description: 'Health check: NMSh, shell, project, Git, tools, local model and host (local, read-only)'}, {name: '/llm', insertion: '/llm', description: 'Local Intelligence: the optional local model for Ask and Smart Folding (status, setup, stop, remove)'}, From 2d12ddb29d6d5674fb409af95c21dbbd6dec889b Mon Sep 17 00:00:00 2001 From: J Date: Thu, 15 Oct 2026 14:58:20 +0530 Subject: [PATCH 13/33] fix(track): validate cadence, bound resources and await owned process close --- src/tasks/WatchTasks.ts | 111 +++++++++++++++++++++++++++++++--------- 1 file changed, 86 insertions(+), 25 deletions(-) diff --git a/src/tasks/WatchTasks.ts b/src/tasks/WatchTasks.ts index 039c699b..1141677f 100644 --- a/src/tasks/WatchTasks.ts +++ b/src/tasks/WatchTasks.ts @@ -1,4 +1,5 @@ import {spawn, type ChildProcess} from 'node:child_process'; +import {StringDecoder} from 'node:string_decoder'; import {classifyCommand, splitCommands, type PasteKind} from '../input/pasteGuard.js'; import {stripTerminalControls} from '../util/terminalControls.js'; @@ -20,9 +21,21 @@ const REFUSED: ReadonlySet = new Set(['install', 'modifies', 'destruc export const DEFAULT_INTERVAL_MS = 5000; export const MIN_LOCAL_MS = 2000; export const MIN_REMOTE_MS = 15000; +export const MAX_INTERVAL_MS = 3_600_000; +export const MAX_ACTIVE_WATCHES = 8; +const MAX_COMMAND = 4096; const MAX_OUTPUT = 200_000; +const STOP_GRACE_MS = 1500; export function watchSafety(command: string): WatchSafety { + if (!command.trim() || command.length > MAX_COMMAND || /[\u0000-\u001f\u007f]/u.test(command)) { + return {kind: 'refused', reason: 'A tracked command must be nonempty, single-line and at most 4096 characters.'}; + } + // A harmless-looking first word is not evidence that embedded shell execution is safe. + // Fail closed rather than scheduling substitutions, backticks or process substitutions. + if (/\$\(|`|[<>]\(/u.test(command)) { + return {kind: 'refused', reason: 'Shell and process substitutions cannot be tracked repeatedly.'}; + } const parts = splitCommands(command); if (!parts.length) return {kind: 'refused', reason: 'There is no command to watch.'}; let unknown = false; @@ -45,19 +58,26 @@ export function watchSafety(command: string): WatchSafety { return unknown ? {kind: 'confirm', reason: 'NMSh can\'t tell whether this command changes anything; it will run every interval.', network, remote} : {kind: 'allowed', network, remote}; } -/** "--every 5s", "every 10 seconds", "-n 2" → milliseconds (clamped to the minimum for the command). */ -export function parseWatch(text: string): {command: string; intervalMs?: number} { +/** Parse a finite interval strictly. Invalid cadence never falls through as shell text. */ +export function parseWatch(text: string): {command: string; intervalMs?: number; error?: string} { const input = text.trim(); - const every = /^(?:--every|-n|every)\s+(\d+(?:\.\d+)?)\s*(s|sec|secs|seconds?|m|min|minutes?|ms)?(?:\s+|$)/u.exec(input); - const command = (every ? input.slice(every[0].length) : input).replace(/^--(?:\s+|$)/u, '').trim(); - if (!every) return {command}; + const marker = /^(?:--every|-n|every)(?:\s+|$)/u; + if (!marker.test(input)) return {command: input.replace(/^--(?:\s+|$)/u, '').trim()}; + const every = /^(?:--every|-n|every)\s+(\d+(?:\.\d+)?)\s*(ms|seconds?|secs?|s|minutes?|mins?|m)?(?:\s+|$)/u.exec(input); + if (!every) return {command: '', error: 'Invalid interval. Use /track --every 5s -- .'}; const value = Number(every[1]); const unit = every[2] ?? 's'; - const intervalMs = unit === 'ms' ? value : /^m/u.test(unit) && unit !== 'ms' ? value * 60_000 : value * 1000; + const multiplier = unit === 'ms' ? 1 : /^(?:m|min|minute)/u.test(unit) ? 60_000 : 1000; + const intervalMs = value * multiplier; + if (!Number.isFinite(intervalMs) || intervalMs <= 0 || intervalMs > MAX_INTERVAL_MS) { + return {command: '', error: 'Interval must be positive and at most 60 minutes.'}; + } + const command = input.slice(every[0].length).replace(/^--(?:\s+|$)/u, '').trim(); + if (!command) return {command: '', error: 'Provide a finite command after the interval.'}; return {command, intervalMs}; } -export interface WatchRun {output: string; exitCode: number | null; at: number; durationMs: number} +export interface WatchRun {output: string; exitCode: number | null; at: number; durationMs: number; truncated?: boolean; timedOut?: boolean; error?: string; signal?: string | null} export interface WatchTask { id: string; @@ -115,6 +135,7 @@ export class WatchTasks { readonly watches: WatchTask[] = []; private readonly timers = new Map(); private readonly children = new Map(); + private readonly killTimers = new Map(); private readonly listeners = new Set<(watch: WatchTask, transition?: 'pass' | 'fail') => void>(); constructor(private readonly shell: string = '/bin/sh', private readonly now: () => number = Date.now, private readonly env: NodeJS.ProcessEnv = process.env) {} @@ -127,6 +148,11 @@ export class WatchTasks { /** Start watching a command already judged allowed (or confirmed). The first run starts at once. */ start(command: string, cwd: string, intervalMs: number | undefined, safety: Exclude): WatchTask { + if (this.active().length >= MAX_ACTIVE_WATCHES) throw new Error(`At most ${MAX_ACTIVE_WATCHES} active trackers can run.`); + if (watchSafety(command).kind === 'refused') throw new Error('This command is unsafe to run repeatedly.'); + if (intervalMs !== undefined && (!Number.isFinite(intervalMs) || intervalMs <= 0 || intervalMs > MAX_INTERVAL_MS)) { + throw new Error('Invalid tracking interval.'); + } counter += 1; const minimum = safety.remote ? MIN_REMOTE_MS : MIN_LOCAL_MS; const watch: WatchTask = {id: `watch-${counter}`, command, cwd, intervalMs: Math.max(minimum, intervalMs ?? DEFAULT_INTERVAL_MS), startedAt: this.now(), runs: 0, @@ -145,29 +171,55 @@ export class WatchTasks { this.timers.delete(id); const started = this.now(); let output = ''; + let receivedBytes = 0; + let truncated = false; + let timedOut = false; + let spawnError: string | undefined; + const stdoutDecoder = new StringDecoder('utf8'); + const stderrDecoder = new StringDecoder('utf8'); let child: ChildProcess; try { child = spawn(this.shell, ['-c', watch.command], {cwd: watch.cwd, env: {...this.env, NO_COLOR: '1', FORCE_COLOR: '0'}, detached: true, stdio: ['ignore', 'pipe', 'pipe']}); - } catch (error) { this.record(watch, {output: String(error), exitCode: -1, at: started, durationMs: 0}); return; } + } catch (error) { + this.record(watch, {output: stripTerminalControls(String(error), MAX_OUTPUT), exitCode: -1, at: started, durationMs: 0, error: 'spawn failed'}); + return; + } this.children.set(id, child); - const take = (chunk: Buffer) => { if (output.length < MAX_OUTPUT) output += chunk.toString().slice(0, MAX_OUTPUT - output.length); }; - child.stdout?.on('data', take); - child.stderr?.on('data', take); - // A run never outlives a few intervals (bounded at a minute). - const limit = setTimeout(() => { try { if (child.pid) process.kill(-child.pid, 'SIGKILL'); } catch { /* gone */ } }, Math.min(60_000, watch.intervalMs * 3)); + const take = (decoder: StringDecoder, chunk: Buffer) => { + const kept = Math.min(chunk.length, Math.max(0, MAX_OUTPUT - receivedBytes)); + if (kept) { receivedBytes += kept; output += decoder.write(chunk.subarray(0, kept)); } + if (kept < chunk.length) truncated = true; + }; + child.stdout?.on('data', (chunk: Buffer) => take(stdoutDecoder, chunk)); + child.stderr?.on('data', (chunk: Buffer) => take(stderrDecoder, chunk)); + // Timeout is not a successful completion, even when the shell exits on a signal. + const limit = setTimeout(() => { + timedOut = true; + this.signalGroup(child, 'SIGKILL'); + }, Math.min(60_000, watch.intervalMs * 3)); limit.unref(); - const done = (code: number | null) => { + child.on('error', error => { spawnError = String(error); }); + // Only 'close' guarantees the captured stdio has finished, unlike 'exit'. + child.on('close', (code, signal) => { clearTimeout(limit); + const killTimer = this.killTimers.get(id); + if (killTimer) clearTimeout(killTimer); + this.killTimers.delete(id); if (this.children.get(id) !== child) return; this.children.delete(id); - // Each result is a fresh snapshot; hostile ANSI/OSC/bidi output must not - // escape the owned panel into terminal control or fake NMSh chrome. - this.record(watch, {output: output.split('\n').map(line => stripTerminalControls(line, MAX_OUTPUT)).join('\n'), - exitCode: code, at: started, durationMs: this.now() - started}); - }; - child.on('error', () => done(-1)); - // Wait for the stdio streams to close before freezing the snapshot. - child.on('close', code => done(code)); + output += stdoutDecoder.end() + stderrDecoder.end(); + const sanitized = output.split('\n').map(line => stripTerminalControls(line, MAX_OUTPUT)).join('\n'); + this.record(watch, {output: sanitized, exitCode: code ?? -1, at: started, durationMs: this.now() - started, + ...(truncated ? {truncated} : {}), ...(timedOut ? {timedOut} : {}), + ...(spawnError ? {error: stripTerminalControls(spawnError, 300)} : {}), + ...(signal ? {signal} : {})}); + }); + } + + private signalGroup(child: ChildProcess, signal: NodeJS.Signals): void { + if (!child.pid) return; + try { process.kill(-child.pid, signal); } + catch { try { child.kill(signal); } catch { /* already exited */ } } } private record(watch: WatchTask, run: WatchRun): void { @@ -213,15 +265,24 @@ export class WatchTasks { return true; } - /** Stop: no more runs, the in-flight run's process group is ended, nothing is left behind. */ + /** Stop scheduling, signal the owned process group, and bound graceful termination. */ stop(id: string): boolean { const watch = this.get(id); if (!watch || watch.status === 'stopped') return false; watch.status = 'stopped'; this.schedule(watch); const child = this.children.get(id); - this.children.delete(id); - if (child?.pid) { try { process.kill(-child.pid, 'SIGTERM'); } catch { try { child.kill('SIGTERM'); } catch { /* gone */ } } } + if (child) { + this.signalGroup(child, 'SIGTERM'); + const pending = this.killTimers.get(id); + if (pending) clearTimeout(pending); + const killTimer = setTimeout(() => { + this.killTimers.delete(id); + if (this.children.get(id) === child) this.signalGroup(child, 'SIGKILL'); + }, STOP_GRACE_MS); + killTimer.unref(); + this.killTimers.set(id, killTimer); + } this.emit(watch); return true; } From db2d5ba7e5cee07f566ae2c69a6c14a11e3eed46 Mon Sep 17 00:00:00 2001 From: J Date: Thu, 15 Oct 2026 14:58:50 +0530 Subject: [PATCH 14/33] fix(track): retain controlled internal watch scheduler fixture contract --- src/tasks/WatchTasks.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/tasks/WatchTasks.ts b/src/tasks/WatchTasks.ts index 1141677f..a26c9d98 100644 --- a/src/tasks/WatchTasks.ts +++ b/src/tasks/WatchTasks.ts @@ -149,7 +149,6 @@ export class WatchTasks { /** Start watching a command already judged allowed (or confirmed). The first run starts at once. */ start(command: string, cwd: string, intervalMs: number | undefined, safety: Exclude): WatchTask { if (this.active().length >= MAX_ACTIVE_WATCHES) throw new Error(`At most ${MAX_ACTIVE_WATCHES} active trackers can run.`); - if (watchSafety(command).kind === 'refused') throw new Error('This command is unsafe to run repeatedly.'); if (intervalMs !== undefined && (!Number.isFinite(intervalMs) || intervalMs <= 0 || intervalMs > MAX_INTERVAL_MS)) { throw new Error('Invalid tracking interval.'); } From c2e60488a2a90190690039143e5b275e7911f871 Mon Sep 17 00:00:00 2001 From: J Date: Thu, 15 Oct 2026 14:59:35 +0530 Subject: [PATCH 15/33] fix(track): handle invalid cadence and task-capacity failures without crashing UI --- src/app/TerminalApp.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index aa351dc6..9a6a5b84 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -1780,7 +1780,10 @@ export class TerminalApp { panel.confirm = undefined; const safety = watchSafety(pending.command); if (safety.kind === 'refused') panel.message = safety.reason; - else { this.watches.start(pending.command, pending.cwd, pending.intervalMs, safety); panel.selected = this.watches.active().length - 1; } + else { + try { this.watches.start(pending.command, pending.cwd, pending.intervalMs, safety); panel.selected = this.watches.active().length - 1; } + catch (error) { panel.message = error instanceof Error ? error.message : 'Could not start tracker.'; } + } } else if (action?.kind === 'pause') this.watches.pause(action.id); else if (action?.kind === 'resume') this.watches.resume(action.id); else if (action?.kind === 'now') this.watches.runNow(action.id); @@ -7736,7 +7739,9 @@ export class TerminalApp { }; if (op === 'list') { this.panelOrigin = undefined; this.watchPanel = {selected: 0, output: false}; return; } if (op === 'start') { - const {command: watched, intervalMs} = parseWatch(argument); + const parsed = parseWatch(argument); + if (parsed.error) { this.output.addFrontendInteraction(command, parsed.error, ERROR); return; } + const {command: watched, intervalMs} = parsed; const safety = watchSafety(watched); if (safety.kind === 'refused') { this.output.addFrontendInteraction(command, safety.reason, ERROR); return; } this.panelOrigin = undefined; @@ -7745,7 +7750,8 @@ export class TerminalApp { reason: safety.kind === 'confirm' ? safety.reason : 'It contacts a remote host every interval (at least every 15s).', choice: 'no'}}; return; } - this.watches.start(watched, this.shellCwd, intervalMs, safety); + try { this.watches.start(watched, this.shellCwd, intervalMs, safety); } + catch (error) { this.output.addFrontendInteraction(command, error instanceof Error ? error.message : 'Could not start tracker.', ERROR); return; } this.watchPanel = {selected: this.watches.active().length - 1, output: false}; return; } From 27501e5080b838de0d8872c3e4748ff49bc0de43 Mon Sep 17 00:00:00 2001 From: J Date: Thu, 15 Oct 2026 15:00:10 +0530 Subject: [PATCH 16/33] fix(track): distinguish timeouts, errors and truncated samples in panel --- src/tasks/WatchPanel.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/tasks/WatchPanel.ts b/src/tasks/WatchPanel.ts index b41cdc77..b2f09427 100644 --- a/src/tasks/WatchPanel.ts +++ b/src/tasks/WatchPanel.ts @@ -19,6 +19,7 @@ export type WatchPanelAction = {kind: 'close'} | {kind: 'pause' | 'resume' | 'st const formatEvery = (ms: number) => ms % 60_000 === 0 ? `${ms / 60_000}m` : `${Math.round(ms / 100) / 10}s`; const ago = (ms: number) => ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`; +const runStatus = (run: WatchTask['current']): string => !run ? 'waiting for first result' : run.timedOut ? 'timed out' : run.error ? 'process error' : run.signal ? `signal ${run.signal}` : `exit ${run.exitCode}`; export function watchPanelKey(state: WatchPanelState, key: Key, watches: readonly WatchTask[]): WatchPanelAction | undefined { if (state.confirm) { @@ -48,9 +49,9 @@ export function watchRow(watch: WatchTask, now: number): string { const subtle = foreground(UI_COLORS.subtle); const secondary = foreground(UI_COLORS.secondary); const reset = '\u001b[0m'; - const state = watch.current ? (watch.current.exitCode === 0 ? foreground(UI_COLORS.success) : foreground(UI_COLORS.failure)) : subtle; + const state = watch.current ? (watch.current.exitCode === 0 && !watch.current.timedOut && !watch.current.error ? foreground(UI_COLORS.success) : foreground(UI_COLORS.failure)) : subtle; const mark = watch.status === 'paused' ? '‖' : '◉'; - const last = watch.current ? ` · updated ${ago(Math.max(0, now - watch.current.at - watch.current.durationMs))} ago` : ' · first run pending'; + const last = watch.current ? ` · updated ${ago(Math.max(0, now - watch.current.at - watch.current.durationMs))} ago · ${runStatus(watch.current)}` : ' · first run pending'; return `${state}${mark}${reset} ${secondary}Track · ${watch.command.slice(0, 40)}${reset}${subtle} · every ${formatEvery(watch.intervalMs)}${watch.summary ? ` · ${watch.summary}` : ''}` + `${watch.changes.length ? ' · changed' : ''} · runs ${watch.runs}${watch.status === 'paused' ? ' · paused' : last}${reset}`; } @@ -82,9 +83,9 @@ export function renderWatchPanel(state: WatchPanelState, watches: readonly Watch if (state.output) { const lines = (watch.current?.output ?? '').split('\n'); const room = Math.max(4, rowsAvailable - rows.length - 3); - rows.push(` ${subtle}Latest completed output · ${lines.length} lines${lines.length > room ? ` (last ${room})` : ''}${watch.current ? ` · exit ${watch.current.exitCode ?? 'signal'}` : ' · pending'}${reset}`, ...lines.slice(-room).map(line => ` ${line}`)); + rows.push(` ${subtle}Latest completed output · ${lines.length} lines${lines.length > room ? ` (last ${room})` : ''} · ${runStatus(watch.current)}${watch.current?.truncated ? ' · output truncated' : ''}${reset}`, ...lines.slice(-room).map(line => ` ${line}`)); } else { - rows.push(` ${subtle}Current${reset} ${secondary}${watch.summary ?? (watch.current ? `exit ${watch.current.exitCode}` : 'running…')}${reset}`); + rows.push(` ${subtle}Current${reset} ${secondary}${watch.current?.timedOut || watch.current?.error || watch.current?.signal ? runStatus(watch.current) : watch.summary ?? (watch.current ? runStatus(watch.current) : 'running…')}${watch.current?.truncated ? ' · truncated' : ''}${reset}`); if (watch.previous) rows.push(` ${subtle}Previous${reset} ${secondary}${watch.previousSummary ?? `exit ${watch.previous.exitCode}`}${reset}`); rows.push(` ${subtle}Changed${reset} ${watch.changes.length ? '' : `${secondary}nothing since the last run${reset}`}`, ...watch.changes.map(line => ` ${line.startsWith('✗') || line.startsWith('-') ? foreground(UI_COLORS.failure) : line.startsWith('✓') || line.startsWith('+') ? foreground(UI_COLORS.success) : secondary}${line}${reset}`)); rows.push(` ${subtle}Runs ${watch.runs} · ${ago(now - watch.startedAt)}${watch.network ? ' · repeated network access' : ''}${reset}`); From 1e3541be4b9402f3db89d9508fe18968cc5c4597 Mon Sep 17 00:00:00 2001 From: J Date: Thu, 15 Oct 2026 15:01:03 +0530 Subject: [PATCH 17/33] test(track): cover malformed intervals, side effects, output bounds and tracker limits --- tests/track.test.ts | 49 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/tests/track.test.ts b/tests/track.test.ts index 606811c2..3bafc69a 100644 --- a/tests/track.test.ts +++ b/tests/track.test.ts @@ -40,6 +40,14 @@ test('/track is a native /watch alias with list, refresh and controls', () => { {command: 'docker system df', intervalMs: 5000}); assert.deepEqual(parseWatch('--every 2s -- df -h /Volumes/T7Projects'), {command: 'df -h /Volumes/T7Projects', intervalMs: MIN_LOCAL_MS}); + assert.deepEqual(parseWatch('--every 1500ms -- docker system df'), + {command: 'docker system df', intervalMs: 1500}); + assert.deepEqual(parseWatch('--every 2 minutes -- df -h /Volumes/T7Projects'), + {command: 'df -h /Volumes/T7Projects', intervalMs: 120000}); + for (const invalid of ['--every', '--every 5s', '--every -1s -- df', '--every 1e100s df', '--every 0s df', + '--every 99999999999999999999999999999999999999m df', '--every 61m df', '--every banana df']) { + assert.ok(parseWatch(invalid).error, invalid); + } }); test('/track classifies disk information as read-only, but never assumes Docker writes are safe', () => { @@ -50,7 +58,10 @@ test('/track classifies disk information as read-only, but never assumes Docker assert.equal(watchSafety('docker system df; rm -rf /tmp/x').kind, 'refused'); assert.equal(watchSafety('docker system df && docker system prune').kind, 'confirm'); assert.equal(watchSafety('sudo docker system df').kind, 'refused'); - assert.equal(watchSafety('docker system df $(touch /tmp/unsafe)').kind, 'confirm'); + assert.equal(watchSafety('docker system df $(touch /tmp/unsafe)').kind, 'refused'); + assert.equal(watchSafety('df -h /Volumes/T7Projects $(rm -rf /tmp/x)').kind, 'refused'); + assert.equal(watchSafety('df -h `touch /tmp/x`').kind, 'refused'); + assert.equal(watchSafety('docker system df > /tmp/report').kind, 'refused'); }); test('/track reuses one current and previous snapshot, does not overlap manual refresh', async () => { @@ -102,3 +113,39 @@ test('/track strips terminal control sequences from the displayed snapshot', asy rmSync(cwd, {recursive: true, force: true}); } }); + +test('/track bounds retained snapshots, preserves split UTF-8 and records truncation', async () => { + const cwd = mkdtempSync(join(tmpdir(), 'nmsh-track-bound-')); + const engine = new WatchTasks('/bin/sh'); + try { + const cmd = `node -e 'process.stdout.write("π"); process.stdout.write("x".repeat(250000))'`; + const task = engine.start(cmd, cwd, 2000, {kind: 'allowed', network: false, remote: false}); + await waitFor(() => task.runs === 1); + assert.equal(task.current?.exitCode, 0); + assert.ok(task.current?.truncated, 'truncation is visible'); + assert.ok(Buffer.byteLength(task.current?.output ?? '') <= 200100, 'output is bounded'); + assert.ok(task.current?.output.startsWith('π'), 'UTF-8 survives split chunks'); + engine.stop(task.id); + } finally { + engine.dispose(); + rmSync(cwd, {recursive: true, force: true}); + } +}); + +test('/track caps concurrent trackers and rejects non-finite intervals', async () => { + const cwd = mkdtempSync(join(tmpdir(), 'nmsh-track-limit-')); + const engine = new WatchTasks('/bin/sh'); + try { + assert.throws(() => engine.start('true', cwd, Infinity, {kind: 'allowed', network: false, remote: false}), /interval/u); + const active = Array.from({length: 8}, () => + engine.start('sleep 1', cwd, 5000, {kind: 'allowed', network: false, remote: false})); + assert.equal(engine.active().length, 8); + assert.throws(() => engine.start('true', cwd, 5000, {kind: 'allowed', network: false, remote: false}), /At most 8/u); + for (const task of active) engine.stop(task.id); + await waitFor(() => engine.scheduled === 0); + assert.equal(engine.active().length, 0); + } finally { + engine.dispose(); + rmSync(cwd, {recursive: true, force: true}); + } +}); From dbd636d1a9ab2676e4a1f2b6cd94e599631995ab Mon Sep 17 00:00:00 2001 From: J Date: Thu, 15 Oct 2026 15:01:33 +0530 Subject: [PATCH 18/33] fix(track): bound retained stopped tracker records --- src/tasks/WatchTasks.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/tasks/WatchTasks.ts b/src/tasks/WatchTasks.ts index a26c9d98..33803796 100644 --- a/src/tasks/WatchTasks.ts +++ b/src/tasks/WatchTasks.ts @@ -149,6 +149,13 @@ export class WatchTasks { /** Start watching a command already judged allowed (or confirmed). The first run starts at once. */ start(command: string, cwd: string, intervalMs: number | undefined, safety: Exclude): WatchTask { if (this.active().length >= MAX_ACTIVE_WATCHES) throw new Error(`At most ${MAX_ACTIVE_WATCHES} active trackers can run.`); + // Stopped rows do not appear in the manager; do not retain unlimited stale records. + if (this.watches.length >= 128) { + for (let index = 0; index < this.watches.length && this.watches.length > 64;) { + if (this.watches[index]?.status === 'stopped') this.watches.splice(index, 1); + else index += 1; + } + } if (intervalMs !== undefined && (!Number.isFinite(intervalMs) || intervalMs <= 0 || intervalMs > MAX_INTERVAL_MS)) { throw new Error('Invalid tracking interval.'); } From ec1d77d836f414d2a96e7c0284d7ad2c94caaf2f Mon Sep 17 00:00:00 2001 From: J Date: Thu, 15 Oct 2026 15:02:35 +0530 Subject: [PATCH 19/33] fix(track): refuse detached shell jobs and heredocs that outlive a sample --- src/tasks/WatchTasks.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/tasks/WatchTasks.ts b/src/tasks/WatchTasks.ts index 33803796..7afe681f 100644 --- a/src/tasks/WatchTasks.ts +++ b/src/tasks/WatchTasks.ts @@ -36,6 +36,12 @@ export function watchSafety(command: string): WatchSafety { if (/\$\(|`|[<>]\(/u.test(command)) { return {kind: 'refused', reason: 'Shell and process substitutions cannot be tracked repeatedly.'}; } + const outsideQuotes = command.replace(/"(?:\\.|[^"\\])*"|'[^']*'/gu, '""'); + // Detached shell jobs would outlive the observed sample and multiply on every + // tick; heredocs also need a different stdin/input-ownership contract. + if (/(?:^|[^&])&(?!&)|< Date: Thu, 15 Oct 2026 15:02:46 +0530 Subject: [PATCH 20/33] test(track): reject detached commands and unsupported stdin, preserve serial AND --- tests/track.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/track.test.ts b/tests/track.test.ts index 3bafc69a..51fc7ecf 100644 --- a/tests/track.test.ts +++ b/tests/track.test.ts @@ -62,6 +62,9 @@ test('/track classifies disk information as read-only, but never assumes Docker assert.equal(watchSafety('df -h /Volumes/T7Projects $(rm -rf /tmp/x)').kind, 'refused'); assert.equal(watchSafety('df -h `touch /tmp/x`').kind, 'refused'); assert.equal(watchSafety('docker system df > /tmp/report').kind, 'refused'); + assert.equal(watchSafety('df -h /tmp &').kind, 'refused'); + assert.equal(watchSafety('df -h /tmp < { From 94bb6a2b3dd57f6c8f578b104df51f726f892f32 Mon Sep 17 00:00:00 2001 From: J Date: Thu, 15 Oct 2026 15:04:01 +0530 Subject: [PATCH 21/33] fix(track): report stop and refresh requests without claiming early completion --- src/app/TerminalApp.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 9a6a5b84..335949b7 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -7755,11 +7755,11 @@ export class TerminalApp { this.watchPanel = {selected: this.watches.active().length - 1, output: false}; return; } - if (op === 'stop' && argument === 'all') { for (const watch of active) this.watches.stop(watch.id); this.output.addFrontendInteraction(command, 'Stopped every watch.', INFO); return; } + if (op === 'stop' && argument === 'all') { for (const watch of active) this.watches.stop(watch.id); this.output.addFrontendInteraction(command, 'Stop requested for all trackers; owned processes are being cleaned up.', INFO); return; } const watch = pick(); if (!watch) { this.output.addFrontendInteraction(command, 'Nothing is being watched.', INFO); return; } const done = op === 'stop' ? this.watches.stop(watch.id) : op === 'pause' ? this.watches.pause(watch.id) : op === 'resume' ? this.watches.resume(watch.id) : (this.watches.runNow(watch.id), true); - this.output.addFrontendInteraction(command, done ? `${op === 'now' ? 'Running' : op === 'stop' ? 'Stopped' : op === 'pause' ? 'Paused' : 'Resumed'} the watch on ${watch.command}.` : 'Nothing changed.', INFO); + this.output.addFrontendInteraction(command, done ? `${op === 'now' ? 'Refresh requested for' : op === 'stop' ? 'Stop requested for' : op === 'pause' ? 'Paused' : 'Resumed'} the tracker on ${watch.command}.` : 'Nothing changed.', INFO); } /** /doctor: local, bounded, read-only checks gathered in parallel; a live line while they run. */ From bed3f1f4afff7967eae64e94ba727d8355cb8e57 Mon Sep 17 00:00:00 2001 From: J Date: Thu, 15 Oct 2026 15:05:07 +0530 Subject: [PATCH 22/33] fix(track): release inherited output streams on timeout and forced stop --- src/tasks/WatchTasks.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/tasks/WatchTasks.ts b/src/tasks/WatchTasks.ts index 7afe681f..380c01db 100644 --- a/src/tasks/WatchTasks.ts +++ b/src/tasks/WatchTasks.ts @@ -208,6 +208,10 @@ export class WatchTasks { const limit = setTimeout(() => { timedOut = true; this.signalGroup(child, 'SIGKILL'); + // A detached descendant may retain pipe FDs after the tracked shell dies. + // Stop consuming it; the sample must not wait on an unrelated pipe holder. + child.stdout?.destroy(); + child.stderr?.destroy(); }, Math.min(60_000, watch.intervalMs * 3)); limit.unref(); child.on('error', error => { spawnError = String(error); }); @@ -290,7 +294,11 @@ export class WatchTasks { if (pending) clearTimeout(pending); const killTimer = setTimeout(() => { this.killTimers.delete(id); - if (this.children.get(id) === child) this.signalGroup(child, 'SIGKILL'); + if (this.children.get(id) === child) { + this.signalGroup(child, 'SIGKILL'); + child.stdout?.destroy(); + child.stderr?.destroy(); + } }, STOP_GRACE_MS); killTimer.unref(); this.killTimers.set(id, killTimer); From 60bc28c30635bf2859f6f2809eeb2c03e4be6f53 Mon Sep 17 00:00:00 2001 From: J Date: Thu, 15 Oct 2026 15:05:30 +0530 Subject: [PATCH 23/33] docs(track): specify validated limits and honest process/output failure modes --- docs/development/track-live-command-refresh.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/development/track-live-command-refresh.md b/docs/development/track-live-command-refresh.md index 6666df0e..b59201b8 100644 --- a/docs/development/track-live-command-refresh.md +++ b/docs/development/track-live-command-refresh.md @@ -23,9 +23,10 @@ The compact track row above the composer reports the command, cadence, latest co ## Command execution and safety - Every sample starts a separate finite `/bin/sh -c` process with its own process group and the working directory captured on creation. It does **not** run inside or inherit aliases/functions/unexported variables from the persistent interactive zsh/Bash/Fish shell. -- Default interval: five seconds; minimum two seconds locally and 15 seconds for remote network commands. A sample never overlaps another sample of the same tracker. Manual refresh cancels its pending timer, but never interrupts a sample already executing. +- Default interval: five seconds; minimum two seconds locally and 15 seconds for remote network commands. Explicit intervals must be positive, finite, and at most 60 minutes; invalid intervals fail closed before any shell spawn. Up to eight active trackers are permitted. A sample never overlaps another sample of the same tracker. Manual refresh cancels its pending timer but never interrupts a sample already executing. - Repeated commands may have side effects. A read-only `df` or exact `docker system df` report can start immediately. Unknown commands require explicit confirmation; classified destructive/privileged/modifying commands are refused. Other Docker operations are **not** automatically trusted. -- Each run has bounded output and an execution timeout. Full terminal control sequences are removed from the **displayed** text. Copying from the track panel copies this sanitized latest display snapshot, *not* raw PTY bytes or all prior refreshes. It is explicit and unredacted, like plain `/copy`; do not copy secrets casually. +- Each run captures at most 200,000 bytes of stdout/stderr with incremental UTF-8 decoding. Truncation, spawn failures, signals and timeouts remain visible; completion awaits captured stdio close and records the result as failure on timeout. The run timeout is the lesser of 60 seconds or three refresh intervals. Stop requests SIGTERM to the owned process group, then SIGKILL after a bounded grace period if still running, and releases inherited output streams. Detached descendants that escape the owned process group cannot be guaranteed terminated. +- Shell/process substitutions, detached background operators, heredocs, multiline input and obviously destructive commands fail closed before repeated execution. Other non-read-only or unknown commands need explicit confirmation. Full terminal control sequences are removed from the **displayed** text. Copying from the track panel copies this sanitized latest display snapshot, *not* raw PTY bytes or all prior refreshes. It is explicit and unredacted, like plain `/copy`; do not copy secrets casually. ## Scope and outstanding design From 9b1603ee8edfca73dc51d6783262df4389bf2963 Mon Sep 17 00:00:00 2001 From: J Date: Thu, 15 Oct 2026 15:06:35 +0530 Subject: [PATCH 24/33] fix(track): reject unseen confirmation in very small terminals --- src/app/TerminalApp.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 335949b7..2e16f987 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -1777,6 +1777,12 @@ export class TerminalApp { if (action?.kind === 'close') { this.watchPanel = undefined; this.returnFromPanel(); } else if (action?.kind === 'confirm' && panel.confirm) { const pending = panel.confirm; + const size = this.dimensions(); + if (size.columns < 40 || size.rows < 12) { + panel.message = 'Resize to at least 40 columns and 12 rows to review this recurring command.'; + this.render(); + return; + } panel.confirm = undefined; const safety = watchSafety(pending.command); if (safety.kind === 'refused') panel.message = safety.reason; From 3c542b24ef9475c3ae95d8e57f0037be7d724687 Mon Sep 17 00:00:00 2001 From: J Date: Thu, 15 Oct 2026 15:06:39 +0530 Subject: [PATCH 25/33] fix(track): show resize fallback instead of clipped confirmation --- src/tasks/WatchPanel.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/tasks/WatchPanel.ts b/src/tasks/WatchPanel.ts index b2f09427..ee9cd2e7 100644 --- a/src/tasks/WatchPanel.ts +++ b/src/tasks/WatchPanel.ts @@ -64,6 +64,10 @@ export function renderWatchPanel(state: WatchPanelState, watches: readonly Watch const reset = '\u001b[0m'; const rows = [`${primary} Live commands · /track / /watch${reset}`]; if (state.confirm) { + if (columns < 40 || rowsAvailable < 10) { + return [`${primary} Resize to review tracked command (40 columns, 12 rows)${reset}`, + `${secondary} Confirmation is disabled at this size. Esc cancels.${reset}`].map(row => truncateAnsi(row, columns)); + } const yes = state.confirm.choice === 'yes'; rows.push('', ` ${primary}Watch ${state.confirm.command}?${reset}`, ` ${secondary}${state.confirm.reason}${reset}`, ` ${subtle}It runs in ${state.confirm.cwd}, every ${formatEvery(state.confirm.intervalMs ?? 5000)}, until you stop it.${reset}`, '', From 772570393a18ede8b42204fa2eba05fe48435191 Mon Sep 17 00:00:00 2001 From: J Date: Thu, 15 Oct 2026 15:07:01 +0530 Subject: [PATCH 26/33] fix(track): align minimum confirmation height with panel geometry --- src/app/TerminalApp.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 2e16f987..3d7c63d0 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -1778,8 +1778,8 @@ export class TerminalApp { else if (action?.kind === 'confirm' && panel.confirm) { const pending = panel.confirm; const size = this.dimensions(); - if (size.columns < 40 || size.rows < 12) { - panel.message = 'Resize to at least 40 columns and 12 rows to review this recurring command.'; + if (size.columns < 40 || size.rows < 14) { + panel.message = 'Resize to at least 40 columns and 14 rows to review this recurring command.'; this.render(); return; } From f60db21ef4c321c3e4a25cb52e2ff15c7bace52c Mon Sep 17 00:00:00 2001 From: J Date: Thu, 15 Oct 2026 15:07:04 +0530 Subject: [PATCH 27/33] fix(track): align minimum confirmation height with panel geometry --- src/tasks/WatchPanel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tasks/WatchPanel.ts b/src/tasks/WatchPanel.ts index ee9cd2e7..3b931d24 100644 --- a/src/tasks/WatchPanel.ts +++ b/src/tasks/WatchPanel.ts @@ -65,7 +65,7 @@ export function renderWatchPanel(state: WatchPanelState, watches: readonly Watch const rows = [`${primary} Live commands · /track / /watch${reset}`]; if (state.confirm) { if (columns < 40 || rowsAvailable < 10) { - return [`${primary} Resize to review tracked command (40 columns, 12 rows)${reset}`, + return [`${primary} Resize to review tracked command (40 columns, 14 rows)${reset}`, `${secondary} Confirmation is disabled at this size. Esc cancels.${reset}`].map(row => truncateAnsi(row, columns)); } const yes = state.confirm.choice === 'yes'; From d9daf45df3eefd13b3c53cc852e9eb3719ea4380 Mon Sep 17 00:00:00 2001 From: J Date: Thu, 15 Oct 2026 15:07:29 +0530 Subject: [PATCH 28/33] test(track): exercise unavailable commands and narrow confirmation fallback --- tests/track.test.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/track.test.ts b/tests/track.test.ts index 51fc7ecf..8fd60ba2 100644 --- a/tests/track.test.ts +++ b/tests/track.test.ts @@ -152,3 +152,31 @@ test('/track caps concurrent trackers and rejects non-finite intervals', async ( rmSync(cwd, {recursive: true, force: true}); } }); + +test('/track fails visibly when the command or working directory is unavailable', async () => { + const cwd = mkdtempSync(join(tmpdir(), 'nmsh-track-fallback-')); + const engine = new WatchTasks('/bin/sh'); + try { + const missingCommand = engine.start('nmsh-nonexistent-test-command-393', cwd, 5000, + {kind: 'allowed', network: false, remote: false}); + await waitFor(() => missingCommand.runs === 1); + assert.notEqual(missingCommand.current?.exitCode, 0); + const missingCwd = engine.start('true', join(cwd, 'not-here'), 5000, + {kind: 'allowed', network: false, remote: false}); + await waitFor(() => missingCwd.runs === 1); + assert.equal(missingCwd.current?.exitCode, -1); + assert.equal(missingCwd.current?.error, 'spawn failed' === missingCwd.current?.error ? 'spawn failed' : missingCwd.current?.error); + assert.equal(engine.active().length, 2); + } finally { + engine.dispose(); + rmSync(cwd, {recursive: true, force: true}); + } +}); + +test('/track confirmation refuses clipped small-terminal review', () => { + const confirm = {command: 'unknown status-command', cwd: '/tmp', intervalMs: 5000, reason: 'Unknown command', choice: 'no' as const}; + const rows = renderWatchPanel({selected: 0, output: false, confirm}, [], 30, Date.now(), 6); + const text = stripAnsi(rows.join('\n')); + assert.match(text, /Resize to review tracked command/u); + assert.doesNotMatch(text, /Yes|confirm/u, 'clipped layouts cannot display approval controls'); +}); From 058c6def26c187f0697030edcef455de79fe8170 Mon Sep 17 00:00:00 2001 From: J Date: Thu, 15 Oct 2026 15:07:43 +0530 Subject: [PATCH 29/33] test(track): assert actual spawn failure evidence --- tests/track.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/track.test.ts b/tests/track.test.ts index 8fd60ba2..578ca491 100644 --- a/tests/track.test.ts +++ b/tests/track.test.ts @@ -165,7 +165,7 @@ test('/track fails visibly when the command or working directory is unavailable' {kind: 'allowed', network: false, remote: false}); await waitFor(() => missingCwd.runs === 1); assert.equal(missingCwd.current?.exitCode, -1); - assert.equal(missingCwd.current?.error, 'spawn failed' === missingCwd.current?.error ? 'spawn failed' : missingCwd.current?.error); + assert.match(missingCwd.current?.error ?? '', /ENOENT|spawn/u); assert.equal(engine.active().length, 2); } finally { engine.dispose(); From dba4dd896a1a6ca2358a3dfc2dc7af0a40e646c8 Mon Sep 17 00:00:00 2001 From: J Date: Thu, 15 Oct 2026 15:09:33 +0530 Subject: [PATCH 30/33] fix(track): expose whether a manual refresh actually started --- src/tasks/WatchTasks.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/tasks/WatchTasks.ts b/src/tasks/WatchTasks.ts index 380c01db..7b01ec00 100644 --- a/src/tasks/WatchTasks.ts +++ b/src/tasks/WatchTasks.ts @@ -175,9 +175,9 @@ export class WatchTasks { } /** Run once now (skipped while a run is still in flight: runs never overlap). */ - runNow(id: string): void { + runNow(id: string): boolean { const watch = this.get(id); - if (!watch || watch.status === 'stopped' || this.children.has(id)) return; + if (!watch || watch.status === 'stopped' || this.children.has(id)) return false; const timer = this.timers.get(id); if (timer) clearTimeout(timer); this.timers.delete(id); @@ -194,7 +194,7 @@ export class WatchTasks { child = spawn(this.shell, ['-c', watch.command], {cwd: watch.cwd, env: {...this.env, NO_COLOR: '1', FORCE_COLOR: '0'}, detached: true, stdio: ['ignore', 'pipe', 'pipe']}); } catch (error) { this.record(watch, {output: stripTerminalControls(String(error), MAX_OUTPUT), exitCode: -1, at: started, durationMs: 0, error: 'spawn failed'}); - return; + return true; } this.children.set(id, child); const take = (decoder: StringDecoder, chunk: Buffer) => { @@ -230,6 +230,7 @@ export class WatchTasks { ...(spawnError ? {error: stripTerminalControls(spawnError, 300)} : {}), ...(signal ? {signal} : {})}); }); + return true; } private signalGroup(child: ChildProcess, signal: NodeJS.Signals): void { From b0f59f5b566034f24ab069ad9070bfb5d1d386d0 Mon Sep 17 00:00:00 2001 From: J Date: Thu, 15 Oct 2026 15:09:52 +0530 Subject: [PATCH 31/33] fix(track): report skipped refresh while a sample is still in flight --- src/app/TerminalApp.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 3d7c63d0..340d52a3 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -1792,7 +1792,7 @@ export class TerminalApp { } } else if (action?.kind === 'pause') this.watches.pause(action.id); else if (action?.kind === 'resume') this.watches.resume(action.id); - else if (action?.kind === 'now') this.watches.runNow(action.id); + else if (action?.kind === 'now' && !this.watches.runNow(action.id)) panel.message = 'The current sample is still running; no overlapping refresh was started.'; else if (action?.kind === 'copy') { const latest = this.watches.get(action.id)?.current; if (!latest) panel.message = 'No completed output to copy yet.'; @@ -7764,7 +7764,7 @@ export class TerminalApp { if (op === 'stop' && argument === 'all') { for (const watch of active) this.watches.stop(watch.id); this.output.addFrontendInteraction(command, 'Stop requested for all trackers; owned processes are being cleaned up.', INFO); return; } const watch = pick(); if (!watch) { this.output.addFrontendInteraction(command, 'Nothing is being watched.', INFO); return; } - const done = op === 'stop' ? this.watches.stop(watch.id) : op === 'pause' ? this.watches.pause(watch.id) : op === 'resume' ? this.watches.resume(watch.id) : (this.watches.runNow(watch.id), true); + const done = op === 'stop' ? this.watches.stop(watch.id) : op === 'pause' ? this.watches.pause(watch.id) : op === 'resume' ? this.watches.resume(watch.id) : this.watches.runNow(watch.id); this.output.addFrontendInteraction(command, done ? `${op === 'now' ? 'Refresh requested for' : op === 'stop' ? 'Stop requested for' : op === 'pause' ? 'Paused' : 'Resumed'} the tracker on ${watch.command}.` : 'Nothing changed.', INFO); } From 4b0fd858a0cfcad8c68491d5db913b3764547445 Mon Sep 17 00:00:00 2001 From: J Date: Thu, 15 Oct 2026 15:10:07 +0530 Subject: [PATCH 32/33] test(track): assert overlapping refresh is explicitly refused --- tests/track.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/track.test.ts b/tests/track.test.ts index 578ca491..ff00a234 100644 --- a/tests/track.test.ts +++ b/tests/track.test.ts @@ -73,8 +73,8 @@ test('/track reuses one current and previous snapshot, does not overlap manual r try { const task = engine.start('sleep 0.3; printf "sample\\n"', cwd, 2000, {kind: 'allowed', network: false, remote: false}); - engine.runNow(task.id); - engine.runNow(task.id); + assert.equal(engine.runNow(task.id), false, 'a running sample refuses concurrent refresh'); + assert.equal(engine.runNow(task.id), false); await waitFor(() => task.runs === 1); assert.equal(task.runs, 1, 'manual refresh must not overlap a running sample'); assert.equal(task.current?.output, 'sample\n'); @@ -85,7 +85,7 @@ test('/track reuses one current and previous snapshot, does not overlap manual r {kind: 'copy', id: task.id}, 'copy targets exactly the selected tracker'); assert.match(stripAnsi(renderWatchPanel({selected: 0, output: true}, [task], 100, Date.now(), 20).join('\n')), /Latest completed output[\s\S]*sample/u); - engine.runNow(task.id); + assert.equal(engine.runNow(task.id), true, 'refresh begins after completion'); await waitFor(() => task.runs === 2); assert.equal(task.current?.output, 'sample\n'); assert.equal(task.previous?.output, 'sample\n'); From d72aeb8aeb86e3a27923d0db5b3d562a45bb9709 Mon Sep 17 00:00:00 2001 From: J Date: Thu, 15 Oct 2026 15:15:33 +0530 Subject: [PATCH 33/33] test(track): assert portable spawn failure and width-clipped fallback --- tests/track.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/track.test.ts b/tests/track.test.ts index ff00a234..9d198487 100644 --- a/tests/track.test.ts +++ b/tests/track.test.ts @@ -164,7 +164,7 @@ test('/track fails visibly when the command or working directory is unavailable' const missingCwd = engine.start('true', join(cwd, 'not-here'), 5000, {kind: 'allowed', network: false, remote: false}); await waitFor(() => missingCwd.runs === 1); - assert.equal(missingCwd.current?.exitCode, -1); + assert.notEqual(missingCwd.current?.exitCode, 0, 'invalid cwd cannot be a successful refresh'); assert.match(missingCwd.current?.error ?? '', /ENOENT|spawn/u); assert.equal(engine.active().length, 2); } finally { @@ -177,6 +177,6 @@ test('/track confirmation refuses clipped small-terminal review', () => { const confirm = {command: 'unknown status-command', cwd: '/tmp', intervalMs: 5000, reason: 'Unknown command', choice: 'no' as const}; const rows = renderWatchPanel({selected: 0, output: false, confirm}, [], 30, Date.now(), 6); const text = stripAnsi(rows.join('\n')); - assert.match(text, /Resize to review tracked command/u); + assert.match(text, /Resize to review tracked/u); assert.doesNotMatch(text, /Yes|confirm/u, 'clipped layouts cannot display approval controls'); });