Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
7ee40e7
feat(track): add live command tracking slash alias and controls
raiseCatError Oct 15, 2026
4b81394
feat(track): accept disk reports and preserve safe finite snapshots
raiseCatError Oct 15, 2026
55119c0
feat(track): show current snapshot freshness in live command view
raiseCatError Oct 15, 2026
fd05602
test(track): exercise aliases, safety, snapshots and control filtering
raiseCatError Oct 15, 2026
e1d0c16
test(track): match multiline snapshot view
raiseCatError Oct 15, 2026
8ab7bc2
feat(track): offer explicit current-snapshot copy in live view
raiseCatError Oct 15, 2026
8f5ff7a
feat(track): copy only the most recent completed snapshot
raiseCatError Oct 15, 2026
10f2cf4
test(track): verify snapshot copy action targets selected tracker
raiseCatError Oct 15, 2026
0cbcb11
docs(track): document live tracker behavior and remaining service scope
raiseCatError Oct 15, 2026
dbf5d9b
fix(track): register Ask command
raiseCatError Oct 15, 2026
f275dbf
fix(track): preserve palette ranking
raiseCatError Oct 15, 2026
f426f59
fix(track): improve help
raiseCatError Oct 15, 2026
2d12ddb
fix(track): validate cadence, bound resources and await owned process…
raiseCatError Oct 15, 2026
db2d5ba
fix(track): retain controlled internal watch scheduler fixture contract
raiseCatError Oct 15, 2026
c2e6048
fix(track): handle invalid cadence and task-capacity failures without…
raiseCatError Oct 15, 2026
27501e5
fix(track): distinguish timeouts, errors and truncated samples in panel
raiseCatError Oct 15, 2026
1e3541b
test(track): cover malformed intervals, side effects, output bounds a…
raiseCatError Oct 15, 2026
dbd636d
fix(track): bound retained stopped tracker records
raiseCatError Oct 15, 2026
ec1d77d
fix(track): refuse detached shell jobs and heredocs that outlive a sa…
raiseCatError Oct 15, 2026
4727c85
test(track): reject detached commands and unsupported stdin, preserve…
raiseCatError Oct 15, 2026
94bb6a2
fix(track): report stop and refresh requests without claiming early c…
raiseCatError Oct 15, 2026
bed3f1f
fix(track): release inherited output streams on timeout and forced stop
raiseCatError Oct 15, 2026
60bc28c
docs(track): specify validated limits and honest process/output failu…
raiseCatError Oct 15, 2026
9b1603e
fix(track): reject unseen confirmation in very small terminals
raiseCatError Oct 15, 2026
3c542b2
fix(track): show resize fallback instead of clipped confirmation
raiseCatError Oct 15, 2026
7725703
fix(track): align minimum confirmation height with panel geometry
raiseCatError Oct 15, 2026
f60db21
fix(track): align minimum confirmation height with panel geometry
raiseCatError Oct 15, 2026
d9daf45
test(track): exercise unavailable commands and narrow confirmation fa…
raiseCatError Oct 15, 2026
058c6de
test(track): assert actual spawn failure evidence
raiseCatError Oct 15, 2026
dba4dd8
fix(track): expose whether a manual refresh actually started
raiseCatError Oct 15, 2026
b0f59f5
fix(track): report skipped refresh while a sample is still in flight
raiseCatError Oct 15, 2026
4b0fd85
test(track): assert overlapping refresh is explicitly refused
raiseCatError Oct 15, 2026
d72aeb8
test(track): assert portable spawn failure and width-clipped fallback
raiseCatError Oct 15, 2026
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
37 changes: 37 additions & 0 deletions docs/development/track-live-command-refresh.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 32 additions & 7 deletions src/app/TerminalApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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. */
Expand Down
2 changes: 1 addition & 1 deletion src/ask/concepts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
13 changes: 9 additions & 4 deletions src/commands/slashCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const META: Record<string, Pick<SlashCommand, 'group' | 'title' | 'alias'>> = {
'/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'},
};
Expand Down Expand Up @@ -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'},
Expand Down Expand Up @@ -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()};
Expand Down
Loading
Loading