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

Filter by extension

Filter by extension

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

- Multi-line progress displays that redraw their own rows (cursor up and erase line, as concurrent downloads and container pulls do) now update in place instead of leaving a copy of every frame in the transcript ([#388](https://github.com/raiseCatError/notMyShell/issues/388)). A program can redraw only its own recent rows, at most a screen height up, never NMSh's lines or earlier commands; `/copy` copies the final rows once. Absolute cursor positioning keeps the previous behavior.
- A queued command whose command word was still being classified when the next prompt reported new shell knowledge no longer keeps the unclassified color ([#408](https://github.com/raiseCatError/notMyShell/issues/408)).
- Paste review and `/track`: a redirect into a file is recognized wherever `>/dev/null` also appears (`echo x > ~/.zshrc 2>/dev/null` was labelled read-only), and `>|` is a redirection, not a pipe.
- `/track` and `/watch` no longer start, without asking, commands that change things through the network or another command: `env <command>`, `NAME=value` prefixes, git's `-c`/`--ext-diff`, `npm publish`/`login`/`x`, `pnpm dlx`, `ssh`, `curl -X POST`/`-d`/`-o file`, plain `wget`; options that write files (`git … --output`, `sort -o`, `jest -u`, `--fix`) are refused. Read-only probes (`ping`, `dig`, `nc -z`, a curl GET) still start at once, and `npm test 2>&1` is no longer refused as a background job ([#393](https://github.com/raiseCatError/notMyShell/issues/393)).
- `/track stop` and a closing window end every process a tracked command left in its process group, including one that ignored TERM; a tracker whose directory disappeared names it instead of reporting `spawn /bin/sh ENOENT`.

## [0.20.0] - 2026-10-18

Expand Down
30 changes: 25 additions & 5 deletions docs/development/track-live-command-refresh.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ keeps a command's own flags from reading as NMSh's.
- Live rows are presentation: find, selection and `/copy` never include them, and nothing accumulates per refresh.
- Stopping a tracker (`/track stop`, or S in the manager) writes its **last result once** into the transcript, so
history keeps it. A tracker whose window closes leaves only its stored lines.
- Above the composer, each active tracker has a compact row; with more than two, one summary row (`4 trackers · 1
failing · /track opens the manager`) replaces them. `clear` removes the block from the transcript; the tracker keeps
running and stays in the manager.
- Safe glyphs use ASCII state marks (`*` live, `=` paused, `o` pending, `#` stopped).

## Execution and safety

Expand All @@ -40,13 +44,29 @@ keeps a command's own flags from reading as NMSh's.
- Default interval 5 s; at least 2 s locally and 15 s for remote network targets; at most 60 minutes. Invalid
intervals fail before anything runs. At most 8 active trackers. A sample never overlaps another of the same tracker;
a refresh while one runs is refused with the reason.
- Repeated commands can repeat side effects. Read-only commands (and the exact `docker system df`) start at once;
unknown ones need an explicit Yes; installing, modifying, destroying, privileged, downloaded-code, `cd`, substitution,
background (`&`) and heredoc commands are refused.
- Repeated commands can repeat side effects, so repetition has its own gate (`src/tasks/repeatSafety.ts`) on top of
the shared command classifier, and it asks when unsure:
- **Start at once:** read-only commands, test and check runners, the exact `docker system df`, and read-only network
probes: `ping`, `dig`, `nslookup`, `host`, `traceroute`, `nc -z`, a curl GET or HEAD to standard output (or
`-o /dev/null`), `wget -O -` or `--spider`, and package queries (`npm view`, `outdated`, `audit`).
- **Ask first (an explicit Yes):** unknown commands; `NAME=value` prefixes and `env <command>` (they change or run
another command); git's `-c`, `--config-env`, `--exec-path` and `--ext-diff`; `git fetch`; anything that sends
data, saves files, signs in, publishes or runs a downloaded package (`curl -X POST`, `-d`, `-o file`, non-HTTP
URLs, plain `wget`, `ssh`, `sftp`, HTTPie, `npm publish`, `npm login`, `npm x`, `pnpm dlx`, `npm audit fix`).
Network targets other than this computer also ask, and their minimum interval is 15 s.
- **Refused:** installing, modifying, destroying, privileged, downloaded-code and `cd` commands; substitutions,
background `&` and heredocs; any output redirection into a file (judged per redirection, so `2>/dev/null` never
excuses `> file`; `2>&1` is fine); options that write or update files (`git … --output`, `sort -o`, `find
-fprint`/`-fls`, `tree -o`, `yq -i`, `--fix`, `--write`, `jest -u`, snapshot updates); `scp` and `rsync` (except
a dry run); `--watch` modes that never finish; shutdown, reboot, halt and power-off.
- Each sample keeps at most 200 kB of stdout and stderr (decoded as UTF-8 across chunk boundaries), says when it was
cut, and has its terminal control sequences removed. A sample is stopped after the lesser of 60 s or three
intervals and shown as timed out. Stopping signals the owned process group (TERM, then KILL after a grace period);
processes that deliberately leave the group are not followed.
intervals and shown as timed out. Stopping signals the owned process group (TERM, then KILL after a grace period).
When a sample ends, anything still in its process group (a child that ignored TERM, or one that closed its output and
kept running) gets TERM and then KILL, so trackers never accumulate processes; a closing window waits for that
bounded grace period. Processes that deliberately leave the group (`setsid`, daemons) are not followed.
- A tracker whose directory disappears says so (`the directory … is no longer available`) and keeps trying at its
interval, so a remounted volume resumes on its own.

## Ownership and limits

Expand Down
12 changes: 11 additions & 1 deletion docs/testing/v021-physical-qa.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,19 @@
- [ ] 5.5 `/track copy` copies only the latest result; find (Ctrl+F) never matches the live rows.
- [ ] 5.6 `/track stop`: the last result appears once in the transcript; `/resume` of the saved session shows it
without any per-refresh copies.
- [ ] 5.7 An unknown command asks for Yes first; `rm`, `sudo`, `cd`, `$(...)`, `&` and heredocs are refused.
- [ ] 5.7 An unknown command asks for Yes first ("Track …?"); `rm`, `sudo`, `cd`, `$(...)`, `&` and heredocs are
refused. Also refused: `/track echo x > /tmp/t 2>/dev/null`, `/track git log --output=/tmp/t`, `/track npx jest -u`.
Asks first: `/track env ls`, `/track curl -X POST http://localhost:1/`, `/track npm publish`, `/track ssh localhost
true`. Starts at once: `/track npm test 2>&1`, `/track curl -s http://localhost:1/` (fails, shows the exit code).
- [ ] 5.8 Close the window with a tracker running: no `sh` or command processes are left (`ps`). `/watch git status`
still opens its manager.
- [ ] 5.9 Four trackers at once (`ls`, `df -h`, `cat f.txt`, a failing one): each block refreshes on its own, the
composer area shows one `4 trackers · 1 failing` row, narrowing the window to 60 columns and back keeps every block
readable, and `/track stop all` leaves each last result once.
- [ ] 5.10 `/track --every 2s -- sh -c 'trap "" TERM; exec >/dev/null 2>&1; exec sleep 300'` (Yes), then `/track stop`:
within 3 seconds `pgrep -f "sleep 300"` finds nothing.
- [ ] 5.11 Safe glyphs (Settings → Appearance) and `NO_COLOR=1`: the status marks are ASCII (`*`, `=`, `#`) and the
status still says live, paused or stopped in words.

## 6. Native modules and diagnostics

Expand Down
4 changes: 2 additions & 2 deletions src/app/TerminalApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ import {ManagedTasks} from '../tasks/ManagedTasks.js';
import {CursorPresenter} from '../cursor/CursorPresenter.js';
import {runDoctor} from '../doctor/doctor.js';
import {parseWatch, watchSafety, WatchTasks, type WatchSafety} from '../tasks/WatchTasks.js';
import {renderWatchPanel, watchPanelKey, watchRow, type WatchPanelState} from '../tasks/WatchPanel.js';
import {renderWatchPanel, watchNoticeRows, watchPanelKey, type WatchPanelState} from '../tasks/WatchPanel.js';
import {finalResultRows, trackBlockRows} from '../tasks/trackBlock.js';
import {explainModules, moduleTimings} from '../prompt/moduleReport.js';
import {analyzePaste, KIND_LABELS, needsPreview, PASTE_EXACT_NOTE, pasteHeader, primaryKind, type PasteAnalysis, nmshCommandNote} from '../input/pasteGuard.js';
Expand Down Expand Up @@ -8100,7 +8100,7 @@ export class TerminalApp {
const now = Date.now();
const suggestion = this.clipboardChangeSuggested && !this.running && this.clipboardChangeWatch?.enabled
? [truncateAnsi(`${SUBTLE}Clipboard changed · /ps reads it for review${RESET}`, columns)] : [];
const rows = [...this.clipboardRows(columns), ...suggestion, ...this.sessionNoticeRows(columns), ...this.taskRows(columns), ...this.watches.active().map(watch => truncateAnsi(watchRow(watch, now), columns)), ...this.pastePreviewRows(columns)];
const rows = [...this.clipboardRows(columns), ...suggestion, ...this.sessionNoticeRows(columns), ...this.taskRows(columns), ...watchNoticeRows(this.watches.active(), now).map(row => truncateAnsi(row, columns)), ...this.pastePreviewRows(columns)];
const background = this.backgroundRows(columns);
return shelf ? [...rows, ...background, shelf] : [...rows, ...background];
}
Expand Down
28 changes: 27 additions & 1 deletion src/input/pasteGuard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export function splitCommands(text: string): Array<{text: string; pipedFrom: boo
if (char === '\n' || char === ';') { push(false); continue; }
if (char === '&' && next === '&') { push(false); index += 1; continue; }
if (char === '|' && next === '|') { push(false); index += 1; continue; }
// `>|` is a redirection (overwrite despite noclobber), not a pipe.
if (char === '|' && current.endsWith('>')) { current += char; continue; }
if (char === '|') { push(true); continue; }
current += char;
}
Expand All @@ -62,7 +64,31 @@ export function splitCommands(text: string): Array<{text: string; pipedFrom: boo

const INTERPRETERS = /^(?:sh|bash|zsh|fish|dash|ksh|python3?|node|perl|ruby|eval|source|\.)$/u;

/** Shell text with quoted strings and escaped characters masked, so operators inside them are not read as operators. */
export function maskQuoted(text: string): string {
return text.replace(/\\[\s\S]|"(?:\\.|[^"\\])*"|'[^']*'/gu, match => match.startsWith('\\') ? '__' : '""');
}

/**
* Whether shell text redirects output into a file. Every redirection is judged on its own target, so `>/dev/null`
* elsewhere in the line never excuses `> ~/.zshrc`. Descriptor duplication (`2>&1`), closing (`>&-`), the null device
* and the standard streams are not files; `>|`, `>&file`, `&>file` and `<>` (opens for writing) are.
*/
export function writesFiles(text: string): boolean {
for (const match of maskQuoted(text).matchAll(/(&>>?|\d*>>|\d*>\||\d*<>|\d*>&|\d*>)\s*([^\s;&|<>()]*)/gu)) {
const operator = match[1] ?? '';
const target = match[2] ?? '';
if (operator.endsWith('<>')) return true;
if (operator.endsWith('>&') && /^(?:\d+|-)$/u.test(target)) continue;
if (/^\/dev\/(?:null|stdout|stderr|fd\/[12])$/u.test(target)) continue;
return true;
}
return false;
}

/** Words of one command (quotes removed for classification only). */
export function commandWords(text: string): string[] { return words(text); }

function words(text: string): string[] {
return text.match(/"(?:\\.|[^"])*"|'[^']*'|\S+/gu)?.map(word => word.replace(/^["']|["']$/gu, '')) ?? [];
}
Expand Down Expand Up @@ -196,7 +222,7 @@ function shellKinds(text: string, pipedFrom: boolean): PasteKind[] {
if (/^(?:mv|cp|mkdir|touch|ln|tee|sed|perl|install|chmod|chown|patch|unzip|tar)$/u.test(command) && !kinds.has('destructive')) {
if (command !== 'sed' || /-i\b|--in-place/u.test(flags)) { kinds.delete('read'); kinds.add('modifies'); }
}
if (/(?:^|[^>&])>>?\s*[^\s&|]/u.test(text.replace(/"(?:\\.|[^"])*"|'[^']*'/gu, '""')) && !/>\s*\/dev\/null/u.test(text)) kinds.add('modifies');
if (writesFiles(text)) kinds.add('modifies');
if (!kinds.size) kinds.add('unknown');
return [...kinds];
}
Expand Down
14 changes: 12 additions & 2 deletions src/tasks/WatchPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {renderControls} from '../ui/controls.js';
import {GLYPHS} from '../ui/glyphs.js';
import {foreground, UI_COLORS} from '../ui/palette.js';
import {truncateAnsi} from '../util/text.js';
import {trackMark} from './trackBlock.js';
import type {WatchTask} from './WatchTasks.js';

/** /track and /watch share one live snapshot view: no duplicated transcript output. */
Expand Down Expand Up @@ -50,12 +51,21 @@ export function watchRow(watch: WatchTask, now: number): string {
const secondary = foreground(UI_COLORS.secondary);
const reset = '\u001b[0m';
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 mark = trackMark(watch.status === 'paused' ? 'paused' : 'live');
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}`;
}

/** The rows above the composer for active watches: one each for up to two, else one summary (each /track block and the manager show the rest). */
export function watchNoticeRows(watches: readonly WatchTask[], now: number): string[] {
if (watches.length <= 2) return watches.map(watch => watchRow(watch, now));
const failing = watches.filter(watch => watch.current && (watch.current.exitCode !== 0 || watch.current.timedOut || watch.current.error)).length;
const paused = watches.filter(watch => watch.status === 'paused').length;
const parts = [`${watches.length} trackers`, ...(failing ? [`${failing} failing`] : []), ...(paused ? [`${paused} paused`] : []), '/track opens the manager'];
return [`${foreground(failing ? UI_COLORS.failure : UI_COLORS.success)}${trackMark('live')}\u001b[0m ${foreground(UI_COLORS.secondary)}${parts.join(' · ')}\u001b[0m`];
}

export function renderWatchPanel(state: WatchPanelState, watches: readonly WatchTask[], columns: number, now: number, rowsAvailable: number): string[] {
const primary = foreground(UI_COLORS.primary);
const secondary = foreground(UI_COLORS.secondary);
Expand All @@ -69,7 +79,7 @@ export function renderWatchPanel(state: WatchPanelState, watches: readonly Watch
`${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}`,
rows.push('', ` ${primary}${state.confirm.track ? 'Track' : '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}`, '',
` ${!yes ? `${accent}${GLYPHS.selection} No${reset}` : `${subtle} No${reset}`} ${yes ? `${accent}${GLYPHS.selection} Yes${reset}` : `${subtle} Yes${reset}`}`, '',
renderControls([['←→', 'choose'], ['Enter', 'confirm'], ['Esc', 'cancel']]));
Expand Down
Loading
Loading