- name
- systools
- description
- System ops toolkit for ports and macOS diagnostics. Use for: inspecting or killing what's on a port ("what's on 3000", "port 8080 is taken"); macOS health snapshots and live memory pressure — CPU temp, load, memory, swap, pageouts ("how's my mac", "why is my mac slow"); releasing leaked test resources — booted iOS simulators, agent-browser Chrome, the shared cargo target dir, the uv cache, browser code-sign clones under /var/folders ("clean up simulators", "清理模拟器", "清理 cargo 缓存", "清理 uv 缓存", "~/.cache/uv is huge", "/var/folders is huge", "code_sign_clone"), and the 23:00 job that closes Hearthstone/Battle.net ("关掉游戏", "炉石没关"); WindowServer CPU/GPU ("WindowServer is hot", "UI feels laggy"); keeping the Mac awake up to 24h ("keep my mac awake", "防止休眠"); the daily pmset wake/sleep schedule ("sleep my mac at 1am", "自动休眠计划"); auditing launchd + cron jobs and Background Task Management items ("定时任务", "why isn't my launch agent running"); repairing PostgreSQL that won't start under brew services ("lock file postmaster.pid already exists", "数据库起不来"); and tuning the local monitoring stack's per-process CPU alerts ("prl_vm_app alert is noisy", "调 CPU 告警阈值"). macOS-only except port management.
# systools
A collection of tools and best practices for common system operations during local development and debugging.
## Port Management
### Check / kill process on a port
**Script:** `scripts/portcheck.sh`
Use this when a user wants to inspect what's running on a port, get detailed process info, or free up a port.
**How to use:**
```bash
# Check what's on a port (info only, no killing)
bash <skill-path>/scripts/portcheck.sh <port>
# Check and kill
bash <skill-path>/scripts/portcheck.sh -k <port>
bash <skill-path>/scripts/portcheck.sh --kill <port>
```
The script displays a key-value report for each process on the port:
- COMMAND, PID, PPID, USER, STAT, %CPU, %MEM, START — from `ps`
- TYPE, NAME — from `lsof` (connection type and address)
- CWD — the process's working directory
With `-k`/`--kill`, the processes are killed after displaying info.
**Typical scenarios:**
- "What's running on port 3000?" → run without `-k`
- "Port 8080 is taken" / "address already in use" → run without `-k` first to show the user, then with `-k` if they confirm
- "Kill whatever is on port 5173" → run with `-k` directly
## macOS Health Check
### System health overview
**Script:** `scripts/mac-health`
**Prerequisites:** macOS only. The script uses `uv run --script` and depends on `psutil`. For CPU temperature, `smctemp` must be installed (`brew tap narugit/tap && brew install smctemp`).
A single command that checks CPU temperature, network throughput, CPU load, memory, swap, and disk usage — all with configurable thresholds. Each metric shows a ✅ or ⚠️ status. The script exits with code 1 if any threshold is exceeded.
**How to use:**
```bash
# Run the health check with default thresholds
<skill-path>/scripts/mac-health
# Override specific thresholds via -t flags
<skill-path>/scripts/mac-health -t cpu_temp_c=60 -t swap_used_gb=0
# Use a custom config file
<skill-path>/scripts/mac-health -c /path/to/config.json
# Generate a config file with defaults at ~/.config/machealth.json
<skill-path>/scripts/mac-health --init-config
```
**What it checks:**
| Metric | Key | Default Threshold |
|--------|-----|-------------------|
| CPU Temperature | `cpu_temp_c` | 80 °C |
| Network RX | `net_rx_mbps` | 50 Mbps |
| Network TX | `net_tx_mbps` | 50 Mbps |
| CPU Load (% of cores) | `cpu_load_pct` | 80% |
| Memory Used | `mem_used_pct` | 85% |
| Swap Used | `swap_used_gb` | 4 GB |
| Disk Used | `disk_used_pct` | 90% |
**Typical scenarios:**
- "How's my system doing?" / "check my mac health" → run `mac-health` and summarize the output
- "Is my CPU overheating?" → run `mac-health` and focus on the CPU Temp line
- "My mac feels slow" → run `mac-health` to check CPU load, memory, and swap — high swap or memory pressure often explains perceived slowness
- "How much disk space do I have?" → run `mac-health` and highlight the Disk lines (it auto-detects external volumes under /Volumes)
- "Swap is too high" → run `mac-health -t swap_used_gb=0` to flag any swap usage
## macOS Memory I/O Analytics
### Monitor memory paging and swap activity
**Script:** `scripts/mac-mem-io`
**Prerequisites:** macOS only. Uses `uv run --script` with Python ≥3.11 (no third-party deps). Calls `vm_stat`, `sysctl vm.swapusage`, and `memory_pressure` under the hood.
This script samples virtual-memory counters at regular intervals and reports real-time I/O rates — page-ins/outs, swap-ins/outs, and compressor activity. It answers the question: "Is my Mac *actively* under memory pressure right now?" as opposed to `mac-health` which gives a point-in-time snapshot of total usage.
**How to use:**
```bash
# Default: 5 samples at 1-second intervals
<skill-path>/scripts/mac-mem-io
# Custom: 10 samples at 2-second intervals
<skill-path>/scripts/mac-mem-io -i 2 -n 10
```
**Output columns:**
| Column | Meaning |
|--------|---------|
| `swap_used` | Current swap in use (GB). High alone doesn't mean trouble — can be historical residue. |
| `swap_free` | Free swap remaining (GB). |
| `mem_free` | System-wide memory free % from `memory_pressure`. |
| `pageins` | Rate of VM pages read into memory (MiB/s). Small non-zero values are common. |
| `pageouts` | Rate of VM pages written out (MiB/s). Sustained non-zero = pressure. |
| `swapins` | Rate of pages read back from swap (MiB/s). Sustained non-zero = swap churn. |
| `swapouts` | Rate of pages written to swap (MiB/s). Sustained non-zero = swap churn. |
| `compress` | Pages compressed per second. High sustained values = tightening memory. |
| `decompress` | Pages decompressed per second. Some activity is normal. |
**Exit code:** 0 if no pageouts/swapins/swapouts detected across all samples, 1 if any were observed. This makes it composable in scripts or health checks.
**How to interpret results:**
- High `swap_used` alone does NOT mean current performance trouble — it may be left over from earlier.
- If `pageouts`, `swapins`, and `swapouts` stay near zero, current memory I/O pressure is low regardless of swap_used.
- If `pageouts` or swap traffic keep rising every sample and the machine feels slow, memory pressure is real.
- Use `mac-health` first for a quick snapshot; reach for `mac-mem-io` when you need to see whether pressure is *active and ongoing*.
**Typical scenarios:**
- "Is my mac swapping right now?" / "memory pressure?" → run `mac-mem-io` and check if pageouts/swapins/swapouts are non-zero
- "My mac feels slow, is it memory?" → run `mac-health` first for overview, then `mac-mem-io` to see if there's active paging
- "Watch memory for the next 30 seconds" → run `mac-mem-io -i 2 -n 15`
- "Is the compressor working hard?" → run `mac-mem-io` and look at compress/decompress columns
- After closing heavy apps: "Did that help?" → run `mac-mem-io -n 3` to confirm pressure dropped
## macOS Dev Resource Cleanup
### Release leaked iOS simulators, agent-browser sessions, stale cargo artifacts, old uv cache entries and orphaned browser code-sign clones
**Script:** `scripts/mac-dev-cleanup`
**Prerequisites:** macOS only. Uses `uv run --script` with Python ≥3.11 (no third-party deps). Wraps `ps`, `xcrun simctl`, `agent-browser close`, `osascript`, `memory_pressure`, for the cargo step `cargo` + [`cargo-sweep`](https://github.com/holmgr/cargo-sweep) (`cargo install cargo-sweep`; the step is skipped if either is missing), for the uv step `uv` + `lsof`, and for the clones step `getconf`.
The two biggest recurring causes of a degraded `mac-health` reading, and neither cleans up after itself:
- **Booted iOS simulators** — each booted device keeps a full `launchd_sim` tree alive: SpringBoard plus dozens of iOS widget extensions at 200–330 MB each. One booted device measured **~196 processes**.
- **Leaked `agent-browser` sessions** — the CLI spawns a detached daemon (PPID 1) plus a ~10-process headless Chrome tree that survives the session that created it. Observed running for **days**, with renderers pinned at ~100% CPU each, ~5 of 10 cores gone.
Plus two that eat disk instead: **the shared cargo target dir** (`build.target-dir` in `~/.cargo/config.toml`, here `~/Library/Caches/cargo-target`) that every Rust project builds into. It only grows — each dependency version, feature set and toolchain leaves its own artifacts — and reached 24 GB before this step existed. And **the uv cache** (`~/.cache/uv`), which keeps every wheel uv ever downloaded and every environment `uv run --script` / `uv run --with` / `uvx` ever built. It showed 13 GB on 2026-10-27; the first run took `du` to 5.6 GB but freed only 3.3 GB of real disk, because the rest was shared with installed venvs (see the uv notes below). And **browser code-sign clones** under `/var/folders/…/X/`: every Chrome that is killed rather than quit leaves a copy of its app bundle there. 35 of them, mostly from agent-browser sessions, made `/private/var/folders` show 81 GB on 2026-10-27, though they held only 0.7 GiB of real disk (see the clones notes below).
And one opt-in step that is **never part of the default run**: `--only games` closes Hearthstone and Battle.net in the evening. Left open, Hearthstone keeps the display awake for hours (it holds a display-sleep assertion *and* injects input events, so the system thinks someone is at the keyboard), and Battle.net's helpers keep competing for CPU with agents during the night's dark wakes.
**How to use:**
```bash
# See what would be cleaned, change nothing (exit 1 = there is work to do)
mac-dev-cleanup --dry-run
# Clean everything
mac-dev-cleanup
# Only one part
mac-dev-cleanup --only sim
mac-dev-cleanup --only browser
mac-dev-cleanup --only cargo
mac-dev-cleanup --only uv
mac-dev-cleanup --only clones
# Safe to run mid-session: spare agent-browser sessions younger than 2h
mac-dev-cleanup --min-age 2
# One-off deeper cargo cleanup: drop what no build used for a week, cap at 15 GiB
mac-dev-cleanup --only cargo --cargo-days 7 --cargo-max-size 15GiB
# One-off deeper uv cleanup: wheels and environments created more than 30 days ago
mac-dev-cleanup --only uv --uv-days 30
# Close games and their launchers (only ever runs when asked for explicitly)
mac-dev-cleanup --only games --dry-run
mac-dev-cleanup --only games
# Behaviour checks (100 assertions, no side effects)
mac-dev-cleanup --selftest
```
**What it does:**
| Step | Action |
|------|--------|
| agent-browser | Groups Chrome processes into sessions by `--user-data-dir=…/agent-browser-chrome-<uuid>`, links each to its spawning daemon via the root process's PPID, then `agent-browser close --all`, then SIGTERM→SIGKILL any survivor |
| simulators | `xcrun simctl shutdown all`, then quit Simulator.app via AppleScript, falling back to signals when it refuses |
| cargo target | Finds the shared target via `cargo metadata` on a throwaway stub project, takes cargo's build locks, then `cargo sweep --time 21` (artifacts no build touched in 21 days) and `cargo sweep --maxsize 20GiB` (least recently used first) |
| uv cache | Skips unless the `uv` binary contains every cache bucket name the script knows, and skips the day if a uv that holds the cache lock started < 10min ago. Then deletes: top-level entries that are not a current bucket (old/foreign bucket versions, temp dirs of dead runs) and archives nothing links to, both if created > 1 day ago; downloaded wheels and cached environments created > `--uv-days` (90) ago. Never touches wheels built from source, or anything a running process's command line points into |
| clones | Lists `X/*.code_sign_clone/code_sign_clone.*` next to the user temp dir, reads each clone's `CFBundleExecutable`, then keeps every clone made within 30s before / 300s after a running browser of that app started (matched by executable, `--type=` helpers excluded) and every clone whose app it can't read. The rest are renamed into a trash dir and deleted |
| games (`--only games`) | Matches Hearthstone (+ its BlizzardBrowser), Battle.net (+ its Helpers) and the Blizzard update Agent by executable path, asks the apps to quit via AppleScript by bundle id, waits 20s, then SIGTERM→SIGKILL whatever is left |
| report | Free-memory % and swap before/after (process steps); target size before/after (cargo step); cache size before/after and the volume's real free-space gain (uv step); clones removed, their `du` size and the volume's real free-space gain (clones step) |
**Exit code:** 0 on success, 2 if a step crashed (the remaining steps still run). Under `--dry-run`, 1 means there is something to clean — composable as a check like `mac-health`.
**Notes / gotchas:**
- **The user's own Chrome is never touched.** Session membership is decided solely by the `agent-browser-chrome-<uuid>` user-data-dir; a plain `/Applications/Google Chrome.app/…/Google Chrome` has no such flag. This is covered by explicit safety assertions in `--selftest`.
- `ps` truncates the command column to terminal width unless `-ww` is passed, which silently hides processes whose `--user-data-dir` sits past the cutoff — the script always uses `ps -Awwo`. Any ad-hoc `ps | grep` for these processes needs the same flag or it will undercount.
- `agent-browser close --all` is all-or-nothing, so `--min-age` switches to selective signalling instead of calling it.
- `simctl shutdown all` preserves device data; only running-app state is lost, and devices re-boot on demand.
- Simulator.app routinely ignores `quit app "Simulator"`; the signal fallback is the normal path, not an error.
- Counting these processes by hand with `ps -A | grep -c <pattern>` self-matches the grep and the parent shell. Use a bracket pattern (`'[s]imruntime'`) or the script's own report.
- Each step runs isolated: on 2026-10-23 `xcrun simctl list` hung for 60s right after the 05:30 wake (0.2s when retried later), and the traceback used to take the cargo step down with it. Now the step is reported as failed, the rest still run, and the exit code is 2.
- **Games step:** Battle.net's Helper processes run from `~/Library/Application Support/Battle.net/Versions/…/battle.net-core.framework`, *not* from inside `Battle.net.app` — match on both. Quit by bundle id (`net.battle.app`, `unity.Blizzard Entertainment.Hearthstone`): Launch Services does not resolve the name "Battle.net" (`open -a Battle.net` fails). The AppleScript is guarded with `is running` (a bare `tell application` would *launch* the app) and wrapped in `ignoring application responses` (a quit confirmation dialog would otherwise hang osascript). The Blizzard Agent has no app to quit and always takes the signal path after the 20s grace. Steam is deliberately not on the list; add a `Game(...)` entry to `GAMES` to extend it.
**Cargo step: why it doesn't cause rebuild churn, and its traps:**
- "Last used" is the atime of the files in `.fingerprint/<unit>-<hash>/`: cargo reads the hash file there on every build, fresh or not, to decide whether to recompile. The intent was that anything a project still builds keeps a fresh atime and survives. **But this Mac updates atime on a read only while atime < mtime** (measured 2026-10-27, see the uv notes): after a unit is rebuilt, the first fresh build bumps its atime once, and later fresh builds leave it alone. So the atime really says "first build after the last rebuild", and a dependency used daily but not recompiled for 21 days is swept and rebuilt once. Tracked in `kb/known-issues.md`.
- **`cargo sweep --installed` / `--toolchains` poison that signal** — they read every fingerprint `.json` (even with `--dry-run`), stamping the whole target "used today", after which `--time` finds nothing for weeks. The script only uses `--time` / `--maxsize` (stat-only), and before each sweep resets any fingerprint `.json` whose atime is newer than its mtime back to its mtime. Cargo itself reads the `.json` only right before rewriting it, so this loses nothing. Old-toolchain artifacts need no `--installed`: after a `rustup update` nobody uses them, and `--time` collects them.
- cargo-sweep takes only a project path and resolves the target via `cargo metadata`; pointing it at the target dir itself fails. The stub project in a temp dir keeps the job independent of any real project.
- If a build holds any of `.cargo-lock` / `.cargo-build-lock` / `.cargo-artifact-lock` (all flock(2)), the step skips the run. While sweeping it holds them itself, so a build that starts meanwhile prints `Blocking waiting for file lock on build directory` and waits (both verified against cargo 1.98).
- **Size units:** cargo-sweep reads `GB` as 10⁹ and sums apparent file sizes, counting the hardlinks between `incremental/` and `deps/` twice, so its total runs ~5% above `du`. Pass `GiB`.
- `~/.cargo/registry` needs none of this: cargo ≥1.88 garbage-collects it on its own.
**uv step: why not `uv cache prune`, and its traps** (all verified against uv 0.9.17 source and this cache):
- **`uv cache prune` cannot run unattended here.** Every `uv` process holds a shared flock on `~/.cache/uv/.lock` for its whole life, and the resident `uv run` jobs (proc-top, condenser-probe) live for weeks, so prune waits forever for its exclusive lock. With `--force` it goes ahead, and its step two **deletes every cached environment unconditionally**, including `environments-v2/proc-top-*`, which the monitoring job is running from. The step does the same garbage collection by hand and skips entries in use.
Voir sur GitHub