nokeo08 7e632b3e9d 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.
2026-08-17 14:36:18 -05:00
2026-06-15 14:31:31 -05:00
2026-08-17 10:58:26 -05:00

Teams Status Keeper

Keeps Microsoft Teams (or any presence-tracking app) from marking you as "Away" by nudging the mouse cursor when the machine has been idle long enough to trigger an idle timeout.

Real user movement always wins: the script never fires while the user is actively using the mouse, and any synthetic sweep aborts the moment the cursor leaves the position the script just commanded.

Requirements

  • Bun >= 1.0.0
  • macOS or Linux (relies on @nut-tree-fork/nut-js for cross-platform mouse + screen control)
  • On macOS: Accessibility permission for the terminal running Bun (System Settings > Privacy & Security > Accessibility)

Install

curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/scripts/install.sh | sh

This installs the latest tagged release from Gitea (the installer resolves it automatically), runs bun install --production under the install dir, and drops a move wrapper on your bin dir. If the latest tag can't be determined — offline, or the API is unreachable — it falls back to the master branch. Pin an exact ref with MOVE_VERSION (see below).

The installer respects the XDG Base Directory Specification:

  • Source lives at $XDG_DATA_HOME/move (default ~/.local/share/move).
  • Wrapper goes to $XDG_BIN_HOME/move (default ~/.local/bin/move). XDG_BIN_HOME is the widely-recognized de facto convention; XDG itself doesn't standardize a user bin dir.

Reinstalling over an existing install

The installer never replaces an existing install silently. When it finds one and it can reach a terminal, it tells you what's there and asks:

==> Found an existing move install (v1.2.0) at /home/you/.local/share/move
Replace it with v1.3.2? [Y/n]

If a config file already exists, it asks separately whether to overwrite it with the shipped defaults (default: no). Both questions come before anything is downloaded or deleted, so declining costs you nothing.

This works under curl ... | sh too: the prompts read from /dev/tty rather than stdin, which the piped script itself occupies.

With no terminal available — CI, cron, a container build — there's nobody to ask, so the installer falls back to its long-standing behavior: an identical version is a no-op, a different version is replaced, and your config is left alone. Use the env vars below to drive it explicitly.

Env vars (all optional):

Var Default Purpose
MOVE_VERSION latest tag Branch or tag to install; auto-resolves to the newest tag, falling back to master. Pin with e.g. v1.0.0.
MOVE_FORCE unset Set to 1 to skip every prompt and reinstall unconditionally. Never touches your config.
MOVE_RESEED_CONFIG unset Set to 1 to overwrite your config with the shipped defaults without asking. The old file is kept as config.json.bak.
XDG_DATA_HOME $HOME/.local/share Where the source tree is installed (under move/).
XDG_BIN_HOME $HOME/.local/bin Where the move wrapper is placed.
XDG_CONFIG_HOME $HOME/.config Where the config file lives (under move/).

Bun must already be installed; the installer fails with a clear pointer to https://bun.sh if it isn't.

If your bin dir isn't on PATH, the installer prints the line to add to your shell rc. It will not modify rc files for you.

Run

move
move --help

Stop with Ctrl+C. If SIGINT arrives mid-sweep, the cursor stays at whichever step was last commanded — by design.

Uninstall

curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/scripts/uninstall.sh | sh

Removes the wrapper at $XDG_BIN_HOME/move and the install tree at $XDG_DATA_HOME/move. Bun stays — it's your runtime, not ours.

Your config file at $XDG_CONFIG_HOME/move/config.json is intentionally left behind, whether you customized it or never touched the seeded defaults. The uninstaller prints a one-line notice pointing at the path so you can remove it manually if you want:

rm -rf "${XDG_CONFIG_HOME:-$HOME/.config}/move"

Usage

Usage: move [options]

Options:
  -h, --help                      Show this help and exit.
  -v, --version                   Print version and exit.
  -e, --edit                      Open the config file in $EDITOR and exit.
  -C, --config <path>             Load defaults from a JSON config file.
                                  Default path: see the Configuration section.
  -m, --move-interval <seconds>   Idle time before a sweep fires. Default: 240.
  -c, --check-interval <seconds>  Cursor poll cadence. Default: 10.
  -d, --step-delay <ms>           Pause between synthetic steps. Default: 50.
  -p, --pattern <name>            Movement strategy. Default: line.
                                  One of: line, diagonal, jitter, walk, arc,
                                  figureEight. 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.

Run move --help for the resolved default config-file path on your system. Numeric overrides are layered onto the defaults via resolveConfig in src/config.ts; time-valued inputs (-m, -c) are expressed in seconds at the CLI boundary and converted to milliseconds internally.

Logging is quiet by default: only the startup banner ("Teams Status Keeper started…") and any error from an unhandled rejection print on a default run. -V / --verbose opens up per-sweep, user-interrupt, and out-of-bounds events.

Invalid input (unknown flag, missing value, non-positive number) prints an error to stderr and exits with code 2.

Configuration

move reads an optional JSON config file at:

${XDG_CONFIG_HOME:-$HOME/.config}/move/config.json

The installer seeds this file with the default values on a fresh install, only if no file already exists at that path. An existing config — yours or from a previous install — is never overwritten silently: the installer asks first, and replaces it only if you say yes (or if you set MOVE_RESEED_CONFIG=1), keeping the old file as config.json.bak either way. MOVE_FORCE=1 reinstalls the software but leaves your config alone. If you remove the file later, move still works: missing defaults fall back to the values baked into the binary (which match what was seeded, since both come from scripts/config.default.json).

Pass -C / --config <path> to point at a different file; in that mode the file must exist.

Precedence

CLI flags  >  config file  >  built-in defaults

CLI flags always win. The config file fills in any flag the user didn't pass on the command line. Built-in defaults fill in anything the file doesn't set.

Example

{
  "moveInterval":  240,
  "checkInterval": 10,
  "stepDelay":     50,
  "pattern":       "line",
  "verbose":       false,
  "loop":          false
}

All keys are optional; supply only the ones you want to override. Keys and units mirror the CLI flags exactly: moveInterval and checkInterval are seconds, stepDelay is milliseconds, pattern is a movement strategy name, verbose and loop are booleans.

The obsolete stepCount / stepSize keys (removed in 1.3.0) are tolerated for backward compatibility: they're ignored with a one-line notice rather than rejected, so a config seeded by an older install keeps working. Sweep size and step count are now properties of each pattern.

Editing

move -e         # or --edit
move --edit --config /path/to/another.json

Opens the active config file in $EDITOR (honors flags in the value, so EDITOR="code --wait" and EDITOR=vim both work). Refuses with exit 2 if:

  • $EDITOR is unset or empty.
  • The target file doesn't exist. (Run move once or reinstall to re-seed the default file.)

The editor's own exit code is propagated, so you can chain move -e && move to validate-by-running after every edit.

Validation

The loader is strict:

  • Root must be a JSON object.
  • Unknown keys are rejected (catches typos like "movInterval").
  • Numeric values must be finite and strictly positive.
  • pattern must resolve to a registered strategy name. Matching ignores case and separators (-, _, spaces), so figure-eight and figureEight are equivalent.
  • verbose must be a boolean.
  • loop must be a boolean.

Any validation failure prints a message naming the file and the offending key to stderr and exits 2.

Loop mode (--loop)

By default a triggered sweep runs once and stops. With -l / --loop (or "loop": true in the config file) the movement instead repeats until you move the mouse (or press Ctrl+C) — a "keep moving until I'm back" mode. It pairs naturally with the roaming patterns:

move --pattern diagonal --loop     # roaming-DVD bounce around the screen
move --pattern figureEight --loop  # traces the eight over and over

In loop mode the cursor is never restored between iterations, and every pattern's bounds policy is forced to reflect, so line and diagonal bounce edge-to-edge across the whole screen instead of ending at the first edge. Interruption is detected via mouse movement only — there is no keyboard hook — so if you resume by typing without touching the mouse, the cursor keeps cycling until you nudge it or stop the process.

Known limitation: verbose and loop can be turned on but not off from the CLI

--verbose and --loop are presence-only flags (there is no --no-verbose / --no-loop). If the config file sets "verbose": true or "loop": true, the CLI cannot force it back off in that invocation. Workarounds: edit the file, or point at a different file with --config.

How it works

For a step-by-step trace of a clean sweep, see the execution happy-path sequence diagram.

The source lives under src/, split into an entry point plus logic modules:

  • src/move.ts is a thin entry point: parses args, dispatches --help / --version, loads the config file, resolves the layered runtime config, and calls runKeeper(config).
  • src/cli.ts owns argument parsing, validation, and help/version output.
  • src/configFile.ts owns optional JSON config-file loading + strict schema validation.
  • src/config.ts exports the Config type (which carries every tunable including verbose), DEFAULT_CONFIG, defaultConfigPath, and the layered resolveConfig overlay function.
  • src/keeper.ts owns the idle-watch loop and the per-sweep glue that wires a strategy to the executor.

Movement itself is split across three seams so patterns are easy to add and everything but the raw nut.js call is unit-testable:

  • src/device.ts is the I/O boundary: a Device interface (getPosition/setPosition/width/height/sleep) plus the nut.js implementation. It's the only module that imports nut.js, and it's injectable, so tests drive the loop and executor with a fake.
  • src/strategies.ts holds the pure movement patterns — each a generator of target points given a start, screen size, config, and RNG — plus the registry and name validation. Adding a pattern is one pure function.
  • src/executor.ts is the single executePath driver: it rounds targets, applies the strategy's bounds policy, paces steps, detects real-user interruption, and restores the cursor on a clean sweep.

Defaults live in src/config.ts as DEFAULT_CONFIG:

Field Default CLI flag Purpose
moveInterval 4 * 60_000 -m, --move-interval Idle time (ms) required before a synthetic sweep fires.
checkInterval 10_000 -c, --check-interval How often (ms) the main loop polls the cursor for real activity.
stepDelay 50 -d, --step-delay Pause (ms) between individual synthetic steps in a sweep.
pattern "line" -p, --pattern Movement strategy name (see Movement strategies below).

-m and -c are accepted in seconds at the CLI; resolveConfig converts to milliseconds before handing the resolved Config to runKeeper.

Main loop (runKeeper)

  1. Print the startup banner (unconditional).
  2. Snapshot lastPos and lastActivity = now.
  3. Every config.checkInterval:
    • If the cursor moved since the last check, the user is active — reset lastActivity and lastPos, continue.
    • Otherwise, if now - lastActivity >= config.moveInterval, call simulateActivity and reset the idleness clock.

Synthetic sweep (simulateActivity + executePath)

  1. simulateActivity snapshots the starting position and current screen dimensions (re-read every sweep so monitor changes are handled), looks up config.pattern in the strategy registry, and builds a MoveContext.
  2. It hands the strategy and context to executePath, which drives the sweep. For each target the strategy yields:
    • Round to whole pixels and apply the strategy's bounds policy (abort / clamp / reflect) to keep it on-screen.
    • Move the cursor there, sleep config.stepDelay.
    • Re-read the cursor. If it isn't at the point we just commanded, the user moved it — log (when --verbose) and return early without snapping back.
  3. On a clean full sweep, restore the cursor to its starting position so the next idle-check sees "no movement" and doesn't misread the synthetic activity as real user input.

In loop mode (--loop) step 2 repeats until the user interrupts: a pattern with an infinite loopPath (line, diagonal) runs that single never-ending path, while the others chain their finite path cycle after cycle. The restore in step 3 is skipped so successive cycles flow from where the last left off, and the bounds policy is forced to reflect for every pattern so edge-seeking motion bounces instead of stopping.

Comparing against the last commanded (rounded) point — not the strategy's ideal, possibly fractional target — is what lets curved and stochastic patterns run without every rounded step looking like user activity. The comparison also allows a small (2px) tolerance, and the clamp/reflect patterns stay a couple of pixels off the screen edge, so sub-pixel cursor placement on scaled or multi-monitor displays isn't misread as the user grabbing the mouse. line uses the abort policy and is unaffected.

Movement strategies

config.pattern selects one of the generators in src/strategies.ts:

Name Motion Steps Size Bounds
line Straight horizontal sweep (the original behavior). 250 250px abort
diagonal Straight line on both axes toward the roomiest corner. 250 250px/axis clamp
jitter Small random hops within a tight radius of the start. 80 30px radius clamp
walk Cumulative random walk; bounces off the screen edges. 200 ±4px/step reflect
arc Smooth quadratic-Bézier curve to a random on-screen point. 120 ~300px clamp
figureEight Traces a figure-eight (lemniscate) and returns to the start. 90 ~250px wide clamp

Each pattern owns its geometry — how many steps it takes and how far it reaches — as constants in src/strategies.ts. Those are properties of the pattern, not user preferences, so there is no knob for sweep size or step count; stepDelay (the per-step pause) is the only pacing lever, and it scales every pattern's total duration. To add a pattern, write one pure generator and register it — the executor supplies bounds, pacing, interrupt, and restore for free.

Why mouse.config.autoDelayMs = 0

nut.js inserts a 100ms delay after every action by default. With two mouse calls per step that would silently more-than-double the duration of a sweep. The code controls cadence itself via config.stepDelay, so the implicit delay is disabled in createNutDevice — the single place nut.js is wired up. Importing the movement modules stays side-effect-free.

For contributors

Clone the repo and bootstrap a dev environment:

git clone https://gitea.cahlen.com/nokeo08/Move.git
cd Move
./scripts/dev-setup.sh

scripts/dev-setup.sh verifies Bun is installed and runs bun install (with devDependencies, unlike the end-user scripts/install.sh). It operates at the repo root regardless of the CWD you invoke it from.

Run from the source tree:

bun run start                  # via the package.json script
bun run src/move.ts            # direct
bun run src/move.ts --help

Or install a global move pointed at your checkout:

bun link
move --help

Files

File Purpose
scripts/install.sh End-user installer; curl-pipeable from Gitea.
scripts/uninstall.sh End-user uninstaller; curl-pipeable from Gitea.
scripts/dev-setup.sh Contributor bootstrap (verify Bun + bun install).
scripts/config.default.json Single source of truth for default values: imported by src/config.ts and copied to $XDG_CONFIG_HOME/move/config.json on a fresh install.
src/move.ts CLI entry point: parses args, dispatches help/version, starts the loop.
src/cli.ts Argument parsing, validation, and help/version output.
src/config.ts Config type (carries every tunable, including verbose), DEFAULT_CONFIG (derived from scripts/config.default.json), defaultConfigPath, and the layered resolveConfig overlay.
src/configFile.ts Optional JSON config-file loader with strict schema validation.
src/editor.ts move --edit: opens the active config file in $EDITOR.
src/errors.ts Shared error types (CliError).
src/keeper.ts Idle-watch loop + per-sweep glue (selects a strategy, calls the executor).
src/device.ts Device I/O seam over nut.js (Point, createNutDevice); the only nut.js importer.
src/strategies.ts Pure movement-pattern generators, the strategy registry, and name validation.
src/executor.ts executePath driver: bounds policy, pacing, interrupt detection, restore.
docs/execution-happy-path.md Sequence diagram + invariants for a clean sweep.
package.json Bun project manifest. Single runtime dep: @nut-tree-fork/nut-js.
tsconfig.json Strict TypeScript config tuned for Bun (ESNext, bundler resolution).
bun.lock Bun's lockfile. Commit this.
LICENSE GPLv3 license text.

License

GPL-3.0-only. See LICENSE.

S
Description
No description provided
Readme GPL-3.0
497 KiB
Languages
TypeScript 86.2%
Shell 13.8%