Add loop mode (--loop): repeat movement until user activity

Introduce a continuous "loop" setting so a triggered sweep keeps the
cursor moving until the user moves the mouse (or Ctrl+C), instead of
firing a single sweep.

- strategies.ts: add optional `loopPath` to MovementStrategy; give `line`
  and `diagonal` infinite loop generators that pick a direction once and
  ramp forever (4px/step). Their finite `path` and declared `bounds` are
  unchanged, so single-sweep behavior is identical.
- executor.ts: add ExecuteOptions { restore?, bounds?, loop? }. Omitting
  options reproduces the original single-sweep contract exactly.
- keeper.ts: in loop mode, run an infinite loopPath once (stopped only by
  interruption) or chain a finite path cycle after cycle; force `reflect`
  bounds for every pattern and suppress the between-cycle restore, so
  line/diagonal bounce edge-to-edge instead of stopping at the first edge.
- config plumbing: new boolean `loop` through config.default.json,
  config.ts, configFile.ts, cli.ts (-l/--loop), and move.ts, mirroring
  the existing `verbose` precedence.
- docs: README loop-mode section + usage/validation updates; CHANGELOG
  Unreleased entry.
- tests: loopPath generators, executor options (bounds override, loop
  selection, restore suppression), config/configFile loop plumbing, and
  keeper-level loop behavior (ramps far vs. bounded single-sweep, chained
  cycles). 79 pass.
This commit is contained in:
2026-08-17 14:36:18 -05:00
parent 1ad724cd33
commit 7e632b3e9d
15 changed files with 397 additions and 31 deletions
+13
View File
@@ -19,6 +19,8 @@
* -p, --pattern Movement strategy name (see strategies.ts).
* -V, --verbose Enable per-sweep / interrupt / bounds logging.
* (`-V` capital because `-v` is `--version`.)
* -l, --loop Loop mode: once triggered, keep moving
* until the user moves the mouse (or Ctrl+C).
*
* Numeric overrides are layered (CLI > file > DEFAULT_CONFIG) by
* `resolveConfig` in `config.ts`; this module only parses and validates.
@@ -55,6 +57,11 @@ export interface ParsedCliArgs {
* even though the CLI has no off-switch today.
*/
verbose: boolean | undefined;
/**
* `true` when `-l`/`--loop` was passed; `undefined` when it was not.
* Same `undefined`-not-`false` rationale as `verbose`.
*/
loop: boolean | undefined;
}
/**
@@ -105,6 +112,7 @@ export function parseCliArgs(): ParsedCliArgs {
"step-delay": { type: "string", short: "d" },
pattern: { type: "string", short: "p" },
verbose: { type: "boolean", short: "V" },
loop: { type: "boolean", short: "l" },
},
strict: true,
allowPositionals: false,
@@ -127,6 +135,7 @@ export function parseCliArgs(): ParsedCliArgs {
stepDelay: parsePositiveNumber("step-delay", values["step-delay"] as string | undefined),
pattern: parsePatternName(values.pattern as string | undefined),
verbose: values.verbose === true ? true : undefined,
loop: values.loop === true ? true : undefined,
};
}
@@ -173,6 +182,9 @@ Options:
Each pattern defines its own size and speed.
-V, --verbose Log every sweep, interrupt, and bounds event
(default prints only the startup banner).
-l, --loop Loop mode: once a sweep is triggered,
keep moving until you move the mouse (or
Ctrl+C), instead of firing a single sweep.
Precedence (highest wins): CLI flags > config file > built-in defaults.
@@ -181,6 +193,7 @@ Examples:
move --move-interval 180 --check-interval 5
move -m 300 -V
move --pattern arc
move --pattern diagonal --loop
move --config ~/myprofile.json
`);
}
+14 -1
View File
@@ -47,6 +47,10 @@ import seedRaw from "../scripts/config.default.json" with { type: "json" };
* pattern owns its own size and step count.
* - `verbose` — whether per-sweep / interrupt / bounds events are
* logged. The startup banner is always printed.
* - `loop` — loop mode: once a sweep is triggered, keep
* repeating the movement until the user moves the mouse
* (or Ctrl+C), rather than firing a single sweep. See
* `keeper.ts` for how the pattern is repeated.
*/
export interface Config {
readonly moveInterval: number;
@@ -54,6 +58,7 @@ export interface Config {
readonly stepDelay: number;
readonly pattern: PatternName;
readonly verbose: boolean;
readonly loop: boolean;
}
/**
@@ -68,6 +73,7 @@ interface SeedShape {
stepDelay: number; // milliseconds
pattern: string; // strategy name
verbose: boolean;
loop: boolean;
}
function assertSeedShape(raw: unknown): asserts raw is SeedShape {
@@ -87,6 +93,9 @@ function assertSeedShape(raw: unknown): asserts raw is SeedShape {
if (typeof r.verbose !== "boolean") {
throw new Error(`scripts/config.default.json: 'verbose' must be a boolean (got ${JSON.stringify(r.verbose)})`);
}
if (typeof r.loop !== "boolean") {
throw new Error(`scripts/config.default.json: 'loop' must be a boolean (got ${JSON.stringify(r.loop)})`);
}
}
assertSeedShape(seedRaw);
@@ -105,6 +114,7 @@ export const DEFAULT_CONFIG: Config = {
stepDelay: seed.stepDelay,
pattern: seed.pattern,
verbose: seed.verbose,
loop: seed.loop,
};
/**
@@ -125,7 +135,8 @@ export const DEFAULT_CONFIG: Config = {
* was not passed and `true` when it was. There is no CLI off-switch
* today, so CLI `false` doesn't occur — a file-set `verbose: true` cannot
* be overridden back to false from the command line (see the Configuration
* section of the README).
* section of the README). `loop` behaves identically: `-l/--loop` sets it
* `true`, and a file-set `loop: true` can't be switched off from the CLI.
*/
export interface ConfigOverrides {
readonly moveInterval: number | undefined;
@@ -133,6 +144,7 @@ export interface ConfigOverrides {
readonly stepDelay: number | undefined;
readonly pattern: string | undefined;
readonly verbose: boolean | undefined;
readonly loop: boolean | undefined;
}
/**
@@ -199,5 +211,6 @@ export function resolveConfig(file: ConfigOverrides | null, cli: ConfigOverrides
stepDelay: pickRaw(cli.stepDelay, file?.stepDelay, DEFAULT_CONFIG.stepDelay),
pattern: pickRaw(cli.pattern, file?.pattern, DEFAULT_CONFIG.pattern),
verbose: pickRaw(cli.verbose, file?.verbose, DEFAULT_CONFIG.verbose),
loop: pickRaw(cli.loop, file?.loop, DEFAULT_CONFIG.loop),
};
}
+6
View File
@@ -12,6 +12,7 @@
* stepDelay number milliseconds, positive
* pattern string a registered strategy name
* verbose boolean
* loop boolean
*
* Unknown keys, wrong types, and non-positive numerics are rejected with a
* `CliError` so the entry point can exit 2 (user error) with a clear
@@ -39,6 +40,7 @@ const ALLOWED_KEYS: ReadonlySet<string> = new Set<string>([
"stepDelay",
"pattern",
"verbose",
"loop",
]);
/**
@@ -173,5 +175,9 @@ export function loadConfigFile(explicitPath: string | undefined): ConfigOverride
"verbose" in parsed
? requireBoolean("verbose", parsed.verbose, path)
: undefined,
loop:
"loop" in parsed
? requireBoolean("loop", parsed.loop, path)
: undefined,
};
}
+44 -5
View File
@@ -47,6 +47,31 @@ export interface Logger {
*/
export type SweepOutcome = "completed" | "interrupted" | "aborted";
/**
* Per-call knobs for `executePath`. All optional; the defaults reproduce the
* original single-sweep behavior exactly, so every existing caller and test
* is unaffected.
*
* - `restore` — restore the cursor to `ctx.start` after a clean sweep.
* Default `true`. Loop (`--loop`) mode passes `false`:
* chained cycles must not snap back between iterations, and an
* infinite `loopPath` never reaches the restore anyway.
* - `bounds` — override the strategy's declared `BoundsPolicy`. Loop mode
* forces `"reflect"` for every pattern so edge-seeking paths
* bounce off the screen instead of aborting (`line`) or
* sticking in a corner (`clamp`). Absent, the strategy's own
* `bounds` is used, so single-sweep behavior is unchanged.
* - `loop` — prefer the strategy's infinite `loopPath` when it defines
* one. Falls back to `path` when the strategy has no
* `loopPath`, so a plain chained-repeat caller can pass this
* unconditionally.
*/
export interface ExecuteOptions {
readonly restore?: boolean;
readonly bounds?: BoundsPolicy;
readonly loop?: boolean;
}
/**
* Slack, in pixels, allowed between the coordinate we commanded and the one
* we read back before calling it real-user activity. Absorbs the sub-pixel
@@ -145,7 +170,15 @@ function timestamp(): string {
* user moved it: return `interrupted` without restoring.
*
* On a clean run the cursor is restored to `ctx.start` so the next
* idle-check sees no net movement, and `completed` is returned.
* idle-check sees no net movement, and `completed` is returned — unless
* `options.restore === false` (loop mode), in which case the cursor is
* left where the last step put it.
*
* `options` (all optional, see `ExecuteOptions`) let loop mode reuse
* this same driver: `bounds` overrides the strategy's policy (loop mode
* forces `reflect`), `loop` selects the strategy's infinite `loopPath`, and
* `restore` suppresses the snap-back. Omitting `options` reproduces the
* original single-sweep contract exactly.
*
* `config` supplies only the pacing (`stepDelay`); a strategy's geometry is
* entirely self-contained, so the path itself needs nothing from it.
@@ -156,13 +189,17 @@ export async function executePath(
device: Device,
log: Logger,
config: Config,
options?: ExecuteOptions,
): Promise<SweepOutcome> {
const { start, width, height } = ctx;
const policy: BoundsPolicy = options?.bounds ?? strategy.bounds;
const path: Iterable<Point> =
options?.loop && strategy.loopPath ? strategy.loopPath(ctx) : strategy.path(ctx);
log.event(`Simulating activity (${strategy.name}) at ${timestamp()}...`);
for (const target of strategy.path(ctx)) {
const point: Point | null = resolveTarget(strategy.bounds, target, width, height);
for (const target of path) {
const point: Point | null = resolveTarget(policy, target, width, height);
if (point === null) {
log.event(`Out of bounds at ${timestamp()}; aborting simulation.`);
return "aborted";
@@ -191,7 +228,9 @@ export async function executePath(
}
}
await device.setPosition({ x: Math.round(start.x), y: Math.round(start.y) });
log.event("Mouse moved.");
if (options?.restore !== false) {
await device.setPosition({ x: Math.round(start.x), y: Math.round(start.y) });
log.event("Mouse moved.");
}
return "completed";
}
+48 -12
View File
@@ -24,7 +24,7 @@
*/
import { createNutDevice, type Device, type Point } from "./device.ts";
import { executePath, type Logger } from "./executor.ts";
import { executePath, type Logger, type SweepOutcome } from "./executor.ts";
import { DEFAULT_PATTERN, STRATEGIES, type MoveContext } from "./strategies.ts";
import type { Config } from "./config.ts";
@@ -46,24 +46,60 @@ function makeLogger(verbose: boolean): Logger {
}
/**
* Perform a single synthetic mouse-activity sweep.
* Perform synthetic mouse activity once the keeper decides the cursor is
* idle.
*
* Snapshots the cursor and screen (re-read every call so monitor changes
* are handled), selects the configured strategy from the registry, and
* hands the resulting path to `executePath`, which owns bounds, pacing,
* interrupt detection, and restore-on-clean. An unknown `config.pattern`
* falls back to the default strategy defensively; validation at the CLI /
* config-file boundary should prevent that from ever happening.
* Snapshots the screen (re-read every call so monitor changes are handled)
* and selects the configured strategy from the registry. An unknown
* `config.pattern` falls back to the default strategy defensively; validation
* at the CLI / config-file boundary should prevent that from ever happening.
*
* Single-sweep mode (`config.loop === false`) runs exactly one sweep via
* `executePath`, which owns bounds, pacing, interrupt detection, and
* restore-on-clean — unchanged from before loop mode existed.
*
* Loop mode (`config.loop === true`) keeps the cursor moving until the
* user moves the mouse (or Ctrl+C). Two things change for every pattern:
* the cursor is never restored between iterations (`restore: false`), and the
* bounds policy is forced to `reflect` so edge-seeking paths bounce off the
* screen instead of aborting (`line`) or sticking in a corner (`clamp`).
* Patterns that define an infinite `loopPath` (`line`, `diagonal`) run it once
* and are stopped only by interruption; the rest have their finite `path`
* chained, re-read from the cursor's current position each cycle. Per-cycle
* event logs are suppressed to avoid unbounded output — one line brackets the
* run at each end.
*/
async function simulateActivity(config: Config, log: Logger, device: Device): Promise<void> {
const start: Point = await device.getPosition();
const width: number = await device.width();
const height: number = await device.height();
const strategy = STRATEGIES[config.pattern] ?? STRATEGIES[DEFAULT_PATTERN]!;
const ctx: MoveContext = { start, width, height, rng: Math.random };
await executePath(strategy, ctx, device, log, config);
if (!config.loop) {
const start: Point = await device.getPosition();
const ctx: MoveContext = { start, width, height, rng: Math.random };
await executePath(strategy, ctx, device, log, config);
return;
}
log.event(`Loop mode (${strategy.name}); repeating until you move the mouse.`);
const cycleLog: Logger = { info: log.info, event: (): void => {} };
const loopOpts = { restore: false, bounds: "reflect" as const, loop: true };
let cycles = 0;
let outcome: SweepOutcome;
do {
const start: Point = await device.getPosition();
const ctx: MoveContext = { start, width, height, rng: Math.random };
outcome = await executePath(strategy, ctx, device, cycleLog, config, loopOpts);
cycles++;
// Spin guard for the chained-repeat path: a finite strategy that
// yielded nothing would otherwise return "completed" instantly in a
// tight loop. Sleeping one stepDelay makes that harmless. An infinite
// loopPath never returns "completed", so this branch is skipped there.
if (outcome === "completed") await device.sleep(config.stepDelay);
} while (outcome === "completed");
log.event(`Loop run ended after ${cycles} cycle(s): ${outcome}.`);
}
/**
+1
View File
@@ -121,6 +121,7 @@ const cliOverrides: ConfigOverrides = {
stepDelay: cliArgs.stepDelay,
pattern: cliArgs.pattern,
verbose: cliArgs.verbose,
loop: cliArgs.loop,
};
const config = resolveConfig(fileOverrides, cliOverrides);
+51 -5
View File
@@ -57,16 +57,31 @@ export interface MoveContext {
/**
* A named movement pattern.
*
* - `name` — registry key, also the value accepted by `--pattern` / the
* `pattern` config key.
* - `bounds` — how the executor confines this pattern to the screen.
* - `path` — pure generator of ideal (possibly fractional) targets,
* emitted in visiting order. Should not re-emit `start`.
* - `name` — registry key, also the value accepted by `--pattern` / the
* `pattern` config key.
* - `bounds` — how the executor confines this pattern to the screen.
* - `path` — pure generator of ideal (possibly fractional) targets,
* emitted in visiting order. Should not re-emit `start`.
* - `loopPath` — optional infinite variant for loop mode (`--loop`).
* A pattern defines it when its finite `path` doesn't chain
* cleanly under repetition: `line`/`diagonal` re-derive their
* direction from the cursor's position every cycle, so chained
* repetition oscillates in a band near an edge instead of
* crossing the screen. An infinite generator picks its
* direction once and ramps forever; the executor's `reflect`
* policy (forced on in loop mode) folds the monotonic ramp
* into an edge-to-edge bounce. Absent this, loop mode simply
* chains `path` — correct for patterns whose finite path is a
* self-contained cyclic unit (`jitter`, `walk`, `arc`,
* `figureEight`). The executor stops either kind on real user
* activity; an infinite `loopPath` therefore only ever ends
* by interruption.
*/
export interface MovementStrategy {
readonly name: string;
readonly bounds: BoundsPolicy;
path(ctx: MoveContext): Iterable<Point>;
loopPath?(ctx: MoveContext): Iterable<Point>;
}
/** Clamp `v` into the inclusive pixel range `[0, max - 1]`. */
@@ -84,8 +99,14 @@ function clamp(v: number, max: number): number {
* vertical movement. 250 one-pixel steps is byte-for-byte the sweep the
* keeper produced before movement patterns existed, which is why its bounds
* policy is `abort` (the direction choice guarantees it never triggers).
*
* In loop mode `loopPath` ramps x in one direction forever (loop mode
* forces `reflect`, so the direction never matters and the ramp bounces edge
* to edge). `LINE_LOOP_STEP` is several pixels per step rather than one so a
* screen crossing takes seconds, not minutes, at the default cadence.
*/
const LINE_STEPS = 250;
const LINE_LOOP_STEP = 4;
export const line: MovementStrategy = {
name: "line",
@@ -97,6 +118,14 @@ export const line: MovementStrategy = {
yield { x: start.x + i * dx, y: start.y };
}
},
*loopPath(ctx: MoveContext): Generator<Point> {
const { start } = ctx;
let x: number = start.x;
for (;;) {
x += LINE_LOOP_STEP;
yield { x, y: start.y };
}
},
};
/**
@@ -104,8 +133,15 @@ export const line: MovementStrategy = {
* chosen independently by available room, so the sweep heads toward the
* roomiest corner and stays on-screen. 250 single-pixel steps per axis
* (≈250px reach), matching `line`'s magnitude.
*
* In loop mode `loopPath` ramps both axes forever under the forced
* `reflect` policy. Because the x and y travel ranges have different spans,
* their triangle waves have different periods, so the path precesses across
* the whole screen — the roaming-DVD bounce — rather than retracing one 45°
* line.
*/
const DIAGONAL_STEPS = 250;
const DIAGONAL_LOOP_STEP = 4;
export const diagonal: MovementStrategy = {
name: "diagonal",
@@ -118,6 +154,16 @@ export const diagonal: MovementStrategy = {
yield { x: start.x + i * dx, y: start.y + i * dy };
}
},
*loopPath(ctx: MoveContext): Generator<Point> {
const { start } = ctx;
let x: number = start.x;
let y: number = start.y;
for (;;) {
x += DIAGONAL_LOOP_STEP;
y += DIAGONAL_LOOP_STEP;
yield { x, y };
}
},
};
/**