diff --git a/docs/development/track-live-command-refresh.md b/docs/development/track-live-command-refresh.md new file mode 100644 index 00000000..b59201b8 --- /dev/null +++ b/docs/development/track-live-command-refresh.md @@ -0,0 +1,37 @@ +# 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. 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 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 + +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. diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index a6175622..340d52a3 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -1777,13 +1777,35 @@ 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 < 14) { + panel.message = 'Resize to at least 40 columns and 14 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; - 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); + 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.'; + 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; @@ -7723,7 +7745,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; @@ -7732,15 +7756,16 @@ 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; } - 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); + 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); } /** /doctor: local, bounded, read-only checks gathered in parallel; a live line while they run. */ 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'], diff --git a/src/commands/slashCommands.ts b/src/commands/slashCommands.ts index 5a9aca71..681e8ef3 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': {alias: '/watch'}, '/btw': {title: 'Open local intelligence'}, '/ask': {alias: '/btw'}, '/caffeinate': {group: 'Tools & integration', title: 'Open Keep Awake'}, '/awake': {alias: '/caffeinate'}, '/zoomies': {alias: '/caffeinate'}, }; @@ -114,7 +115,8 @@ 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)'}, {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()}; diff --git a/src/tasks/WatchPanel.ts b/src/tasks/WatchPanel.ts index 2fddd282..3b931d24 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. */ @@ -15,10 +15,11 @@ 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`; +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) { @@ -37,6 +38,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; @@ -47,10 +49,11 @@ 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' ? '‖' : '◉'; - 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 · ${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}`; } export function renderWatchPanel(state: WatchPanelState, watches: readonly WatchTask[], columns: number, now: number, rowsAvailable: number): string[] { @@ -59,8 +62,12 @@ 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) { + if (columns < 40 || rowsAvailable < 10) { + 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'; 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}`, '', @@ -69,7 +76,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,14 +87,14 @@ 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})` : ''} · ${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}`); } 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)); } diff --git a/src/tasks/WatchTasks.ts b/src/tasks/WatchTasks.ts index d051bab1..7b01ec00 100644 --- a/src/tasks/WatchTasks.ts +++ b/src/tasks/WatchTasks.ts @@ -1,5 +1,7 @@ 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'; /** * /watch: NMSh runs a command again and again on its own schedule (never a @@ -19,9 +21,27 @@ 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 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 (/(?:^|[^&])&(?!&)|<.'}; 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}; + 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; @@ -109,6 +141,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) {} @@ -121,6 +154,17 @@ 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.'); + } 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, @@ -131,30 +175,68 @@ 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); 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 true; + } 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'); + // 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(); - 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); - this.record(watch, {output: output.replace(/\u001b\[[0-?]*[ -/]*[@-~]/gu, ''), exitCode: code, at: started, durationMs: this.now() - started}); - }; - child.on('error', () => done(-1)); - child.on('exit', 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} : {})}); + }); + return true; + } + + 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 { @@ -200,15 +282,28 @@ 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'); + child.stdout?.destroy(); + child.stderr?.destroy(); + } + }, STOP_GRACE_MS); + killTimer.unref(); + this.killTimers.set(id, killTimer); + } this.emit(watch); return true; } diff --git a/tests/track.test.ts b/tests/track.test.ts new file mode 100644 index 00000000..9d198487 --- /dev/null +++ b/tests/track.test.ts @@ -0,0 +1,182 @@ +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, watchPanelKey, 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}); + 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', () => { + 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, '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'); + assert.equal(watchSafety('df -h /tmp &').kind, 'refused'); + assert.equal(watchSafety('df -h /tmp < { + 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}); + 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'); + 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); + 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'); + 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}); + } +}); + +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}); + } +}); + +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.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 { + 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/u); + assert.doesNotMatch(text, /Yes|confirm/u, 'clipped layouts cannot display approval controls'); +});