maintaining-windows-health
Hands-on playbook for Windows 11 disk cleanup, dev-machine optimization, and proactive health alerting. Use when the PC is full or slow, when a BSOD / Kernel-Power 41 / crash dump / commit-memory pressure happened, when the user asks to free disk space, audit storage, set up disk
Install
npx skills add https://github.com/CodeAlive-AI/ai-driven-development/tree/main/skills/maintaining-windows-health
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install codealive-ai-ai-driven-development@llmmart
git clone https://github.com/CodeAlive-AI/ai-driven-development.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole codealive-ai/ai-driven-development collection as a plugin from our marketplace. Git is the plain clone.
README
maintaining-windows-health
A hands-on playbook skill for Windows 11 disk cleanup, dev-machine optimization, and proactive health alerting. It is the Windows port of maintaining-macos-health — same three-layer architecture and the same drift-protection safety invariant, rebuilt around Windows-native tooling.
Recovery and prevention, not blind deletion. The skill follows a managed cycle: inventory → classify → delete only with Microsoft-supported tools → verify integrity → keep a rollback path.
What it does
Three layers, mirroring the macOS skill:
- Triage (
references/triage.md) — classify which signal fired: disk-driven, commit-memory-driven, BSOD/crash, or "feels slow" — with a read-only PowerShell snapshot. - Recovery (
references/cleanup-tiers.md,never-touch.md,native-tools.md) — a 10-tier, risk-ordered cleanup playbook (Storage Sense → discuss-first), a hard blacklist of what never to delete, and the native-tooling safety floor + project-artifact purge map. - Automation / alerting (
references/alerting.md,assets/*.ps1) — a drift-protected HTML cleanup UI plus a Task Scheduler + BurntToast alerter (3 CRITICAL-only triggers, hysteresis, calibration window).
The safety invariant
scan → build JSON → user picks in the HTML UI → selection JSON → apply deletes ONLY selected_items
assets/apply-cleanup-selection.py is the only sanctioned way to apply a cleanup. It never hand-rolls Remove-Item; it reads the user's picks from the selection JSON and validates every command before running it. The validator is a Windows rewrite (not a translation) of the macOS one, because NTFS + PowerShell offer several ways to spell a protected path that a naive check misses:
- NTFS path canonicalization (separator unification,
..collapse, trailing dot/space stripping) like Win32GetFullPath - refusal of UNC/device paths (
\\?\,\\.\), 8.3 short names (PROGRA~1), Alternate Data Streams, and non-filesystem providers (HKLM:,Env:) - a longest-prefix-wins allow/deny classifier so carve-outs resolve correctly (
C:\Windowsdenied, butC:\Windows\Tempallowed;C:\Users\meallowed, butC:\Users\me\.sshdenied) - a two-tier wrapper model: cache tools (npm/docker/dotnet…) are trusted; irreversible tools (
vssadmin delete, DISM/ResetBase,wevtutil cl,pnputil /delete-driver,powercfg /h off) require an explicit protected-override - a refusal of command chaining/injection metacharacters (
; | & \$( :: > <`)
Quick start
Read-only preflight audit:
.\assets\Audit-WinHealth.ps1 # drives, Component Store, shadow storage, drivers, CFA, KFM, BitLocker, reparse points, dumps
Interactive cleanup (requires Python 3 for the HTML UI):
python3 .\assets\render-cleanup-plan.py %TEMP%\cleanup-data.json # opens the picker, writes the selection JSON
python3 .\assets\apply-cleanup-selection.py %TEMP%\cleanup-selection-<ts>.json # applies only what was picked
Install the alerter (non-elevated, interactive session — required for toasts):
.\assets\Install-WinHealthCheck.ps1 # installs BurntToast, registers the 5-min task, runs once
.\assets\Install-WinHealthCheck.ps1 -Test # synthetic disk alert to confirm the toast pipeline
File map
SKILL.md agent entry point (workflows A–D, safety rules, quirks)
references/
triage.md which signal fired (read-only snapshot)
cleanup-tiers.md 10 risk-ordered tiers, PowerShell blocks
never-touch.md hard blacklist + Windows-only dangers
native-tools.md safety floor, purge map, validator rules, 3rd-party caveats
alerting.md Task Scheduler + BurntToast + ntfy alerter design
assets/
win-health-check.ps1 the monitor (PS 5.1 compatible)
win-health-check.config.ps1 thresholds
Install-WinHealthCheck.ps1 registers the task (interactive session); -Test / -Uninstall
Audit-WinHealth.ps1 read-only preflight inventory
render-cleanup-plan.py HTML cleanup UI (cross-platform, Python 3)
apply-cleanup-selection.py the only sanctioned apply path (Windows validator)
test_validate_command.py adversarial validator tests (safety-core gate)
Key Windows-specific guardrails
- Toasts need an interactive session. The scheduled task is registered as the logged-on user (
LogonType Interactive), never SYSTEM — a SYSTEM task's toasts silently no-op. Remove-Itemis permanent (no Recycle Bin) and recursive deletes can escape through junctions — always-LiteralPath, never recurse a reparse point.- OneDrive KFM / Files On-Demand — deleting redirected/placeholder files propagates to the cloud and every device.
- Clean with commands, not folders — WinSxS via DISM, Driver Store via pnputil, shadows via vssadmin, logs via wevtutil, hiberfil via powercfg. The registry is never a cleanup target.
- Commit %, not "available MB", is the memory signal —
Available MBytesincludes reclaimable standby/cache.
Verification status
Authored and statically verified on a macOS machine (no Windows runtime available):
- ✅ Verified here: PowerShell AST parse of all
.ps1(viapwsh),py_compileof both Python scripts, and the adversarial validator suite (test_validate_command.py) — pure NTFS string logic that runs on any OS and is the safety-core gate. - ⚠️ Requires a Windows smoke-test before fully trusting: real NTFS behavior of
GetFullPath/8.3/ADS/reparse resolution, toast rendering and AUMID attribution, SYSTEM-vs-interactive session behavior, Task Scheduler registration semantics on S0ix/battery, and the exact output of DISM/vssadmin/wevtutil/pnputil. Gate the alerter and apply-on-real-paths behind this smoke-test.
Credits & sources
Built on official Microsoft Learn guidance for Storage Sense, cleanmgr, DISM (WinSxS), pnputil, vssadmin, wevtutil, powercfg, Get-WinEvent, Task Scheduler, and BurntToast, plus the macOS sibling's incident-validated architecture. See references/ for inline citations of the behavior each rule depends on.
Skill manifest
Maintaining Windows 11 Health
Recovery and prevention playbook for Windows 11 disk and memory crises. A Windows port of the maintaining-macos-health skill: same three layers (triage → tiered recovery → automation/alerting) and the same safety invariant — scan → JSON → user picks in a UI → apply deletes only what was picked — but with Windows-native tooling and Windows-specific "never touch" rules. The same playbook works for routine cleanup or first-time setup on a new machine.
Table of contents
- When to use
- Skill layout
- Core mental model
- Standard workflows
- Safety rules (non-negotiable)
- Domain quirks captured
- Outcomes scale
When to use
Trigger on any of:
- Disk free < 20 % or user complains about being out of space
- BSOD / unexpected reboot / Kernel-Power 41 / WHEA error / a new crash dump in
C:\Windows\Minidump - "PC is slow", commit pressure high (
% Committed Bytes In Use> 85), pagefile growing - User wants to set up monitoring/alerting from scratch
- Migration to a new Windows PC → restore the same alerter
- General "clean my PC" / "audit storage" / "free space" requests
Skill layout
| File | Use for |
|---|---|
references/triage.md |
First 5 minutes — which signal fired (disk / commit-memory / BSOD-crash / "feels slow"), read-only snapshot |
references/cleanup-tiers.md |
Tiered cleanup playbook (10 tiers, low-risk → discuss-first), copy-paste-safe PowerShell blocks |
references/never-touch.md |
Categories that must not be deleted even elevated (hard-protected prefixes synced with the validator + Windows-only dangers) |
references/native-tools.md |
The safety floor: native Microsoft tooling, project-artifact purge marker→target map, the apply-script validator rules, third-party caveats |
references/alerting.md |
Full alerter design: 3 CRITICAL-only triggers, hysteresis, calibration, Task Scheduler interactive session, BurntToast + ntfy, S0ix/battery |
assets/win-health-check.ps1 |
Production PowerShell monitor (PS 5.1 compatible) |
assets/win-health-check.config.ps1 |
Default thresholds |
assets/Install-WinHealthCheck.ps1 |
Registers the scheduled task in the interactive user session; -Test / -Uninstall |
assets/Audit-WinHealth.ps1 |
Read-only preflight inventory (drives, Component Store, shadow storage, profiles, drivers, CFA, KFM, BitLocker, reparse points, dumps) |
assets/render-cleanup-plan.py |
Interactive HTML cleanup-plan UI. Renders categorised checkboxes from a JSON of scan findings, serves on 127.0.0.1:18347, opens the browser, waits for the user's selection, writes it to %TEMP%\cleanup-selection-<ts>.json. Used by Workflow A. Requires Python 3. |
assets/apply-cleanup-selection.py |
The only sanctioned way to apply a cleanup selection. Reads selected_items from a selection JSON and executes each item's command via PowerShell. Windows-rewritten validator (NTFS canonicalization, deny/allow longest-prefix, provider/UNC/ADS/8.3/chaining refusal, two-tier wrappers) + operations log. Supports --dry-run. |
assets/test_validate_command.py |
Adversarial unit tests for the validator — the safety-core gate. Runs on any OS. |
Read the relevant reference before acting. Do NOT operate from memory of these files — the details are calibrated to Windows-specific failure modes and small changes break safety.
Core mental model
- Native tooling is the safety floor. Unlike macOS (Mole), Windows has no mature community safety tool — so Storage Sense / Cleanup recommendations /
cleanmgr/ DISM do the heavy lifting, and the apply-script validator is the only line behind project-artifact purge. Use the supported command for anything the OS maintains itself. - Clean with commands, not by deleting folders. WinSxS → DISM; Driver Store → pnputil; shadow copies → vssadmin; Event Logs → wevtutil; hiberfil → powercfg. Hand-deleting these corrupts the OS.
- Commit %, not "available MB", is the memory signal.
Available MBytesincludes reclaimable standby/cache; commit-limit exhaustion is the real Windows OOM. - Drift protection via the selection JSON. The HTML UI writes exactly what the user picked;
apply-cleanup-selection.pydeletes onlyselected_items. Never hand-rollRemove-Itemin the apply phase. - Escalate conservatively. Start zero-risk (Storage Sense, caches, Recycle Bin), only reach project artifacts, Docker/WSL VHDX, Component Store, and elevated/discuss-first tiers if needed.
Standard workflows
A. "Free space NOW" (incident response)
- Triage — read
references/triage.md, identify which signal fired and how urgent. Runassets/Audit-WinHealth.ps1(read-only) for the preflight inventory (drives, Component Store, shadow storage, drivers, CFA / OneDrive KFM / BitLocker / reparse points). - Snapshot baseline — record free GB on the target drive.
- Run all scans, don't delete yet — work the tiers in
references/cleanup-tiers.mdin inventory mode (list candidates, sizes, ages), including the large-file visibility pass. Capture everything before opening the browser; deletion comes only after the user picks via the UI. The cleanup UI should be a single final questionnaire containing safe cleanup candidates, protected/discuss-first items, and large suspicious user/workload files together, not a sequence of separate UI rounds. - Python gate — the cleanup UI needs Python 3. Check for a real interpreter (
py -3 --version, or apython.exethat isn't the Microsoft Store alias). If Python is missing, ask the user for permission to install it (winget install Python.Python.3.12), then continue. Do not silently skip the UI. - Resolve unknown items before building JSON — for every candidate > 500 MB you cannot explain in one sentence (unfamiliar app/folder, vendor cache, VM/VHDX, model weights), research it first: check
references/never-touch.md, then delegate a quick lookup to theweb-searchersubagent ("what is<path>on Windows 11, safe to delete in 2026"). Write a concretedescription(1–3 sentences in the user's language) into the item. Never show vague placeholders like "unknown". - Show large-but-not-safe items too — any explainable item > 500 MB that is user-owned or workload-owned but not a safe cache must still appear in the same final UI, normally
protected: true,default_selected: false, with a concretewarning. Examples: old.7z/.zip/.isoarchives,.gguf/.safetensorsmodel weights, project/game assets, app diagnostic dumps, profiler snapshots, synced-folder media, and VHDX files. Visibility is required because only the user can know whether these are still needed. Do not include hard-protected system internals as bare-delete candidates; show them only through supported tools, or omit deletion entirely if no supported action exists. - Build one data JSON — every candidate becomes a structured
item(id, label, path, size_bytes, age_days, kind, PowerShellcommand, mandatorydescription, optionalprotected+warning). Use the schema inassets/render-cleanup-plan.py. Irreversible-tool commands (DISM/ResetBase,vssadmin delete,wevtutil cl,pnputil /delete-driver,powercfg /h off) should be markedprotected: true. Do not open the UI until this unified JSON contains both safe default-selected items and protected unchecked items. - Render and open the cleanup UI:
It serves onpython3 <skill>\assets\render-cleanup-plan.py %TEMP%\cleanup-data-<ts>.json127.0.0.1:18347, opens the browser, and blocks until Submit/Cancel. On submit it writes%TEMP%\cleanup-selection-<ts>.json. Tell the user out loud: "браузер открыт — поставь галочки, нажми Submit, потом пингани меня." Then stop and wait. - After the user pings — read the selection JSON, render their choices back in chat (categories, item list, total GB, any protected overrides flagged), and ask one explicit confirmation before deleting.
- Apply via the helper — never hand-rolled
Remove-Item:
python3 <skill>\assets\apply-cleanup-selection.py %TEMP%\cleanup-selection-<ts>.json
# add --scan-root D:\projects to allow bare deletes on an external dev drive
It reads selected_items, validates each command (NTFS canonicalization, deny/allow longest-prefix, provider/UNC/ADS/8.3/chaining refusal), skips protected items not in protected_overrides, runs each via powershell.exe, and logs to %LOCALAPPDATA%\win-health\operations.log. --dry-run previews. The selection JSON is the single source of truth.
11. Post-check — report the free-space delta, then Dism /Online /Cleanup-Image /CheckHealth + sfc /scannow to confirm nothing was broken. Stop at the goal.
Hard-protected items (per references/never-touch.md) must always appear in the UI with "protected": true + a concrete warning — the UI dims them and requires a per-item confirm before they can be checked. Never omit a protected item user data depends on; visibility teaches the surrounding risk.
B. "Set up alerting" (new machine or first time)
Run a non-elevated PowerShell as the user who should receive alerts (interactive session — required for toasts):
cd <skill>\assets
.\Install-WinHealthCheck.ps1 # installs BurntToast, copies script+config, registers the task, runs once
Then read references/alerting.md for tuning. The task is registered in the interactive user session (NOT SYSTEM — that silently swallows toasts), every 5 minutes, -StartWhenAvailable, battery-friendly. The first 7-day calibration window is silent (logs only). Verify with .\Install-WinHealthCheck.ps1 -Test.
C. Alerter stopped working / making noise
Read references/alerting.md § Troubleshooting. Common causes:
- Task running as SYSTEM / "whether user is logged on or not" → toasts silently never render. Re-run the installer (interactive principal).
- BurntToast not installed → toast attributed to "PowerShell" or absent.
Install-Module BurntToast -Scope CurrentUser. - Laptop never checks → was AC-only / paused in sleep. Re-run installer (battery flags +
-StartWhenAvailable). - Constant alerts during heavy work → create
%LOCALAPPDATA%\win-health\silent, raise thresholds, or increase hysteresis.
D. "Uninstall an app cleanly"
Prefer winget uninstall --id <App.Id> (clean, supported). For apps with stubborn leftovers, BCUninstaller (review the leftover list) is the dev-friendly option. Never hand-delete Program Files install dirs or registry keys to "remove" an app — that orphans the MSI/uninstall state. Always confirm before removing user data folders.
Safety rules (non-negotiable)
- Never delete without dry-run + user confirmation for any tier ≥ 5 or any elevated operation.
- Never bypass
references/never-touch.md— even if the user explicitly asks. Push back, explain the consequence. - Clean with commands, not folders. WinSxS via DISM, Driver Store via pnputil, shadows via vssadmin, logs via wevtutil (export before clear), hiberfil via powercfg. Never
Remove-Itemthese. - The registry is never a cleanup target. No registry cleaners (Microsoft-unsupported). The validator refuses registry-provider deletes.
- Apply phase reads only the selection JSON. Never hand-roll
Remove-Itemor hard-code paths from the earlier scan when applying — that's how you delete items the user unchecked. Useassets/apply-cleanup-selection.py, which iteratesselected_itemsonly. Remove-Itemis permanent (no Recycle Bin) and recursive deletes can escape through junctions — always-LiteralPath, never recurse a reparse point. Prefer move-to-quarantine when the user is unsure.- No auto-cleanup tied to alerts. Alerts notify; the human decides.
- Keep crash dumps until triaged — they're forensic evidence, not cleanup.
Domain quirks captured
- SYSTEM session can't show toasts. A scheduled task as SYSTEM executes but its toasts silently no-op (Session 0 has no desktop). Register the task in the interactive user session — the Windows analog of the macOS "osascript → Script Editor" trap.
Remove-Item -Recursefollows junctions and can delete the target's contents — the classic data-loss bug. Dev trees are full of junctions (npm/pnpm store, Docker, WSL). Use-LiteralPath; never recurse aFILE_ATTRIBUTE_REPARSE_POINT.Remove-Itembypasses the Recycle Bin — deletion is immediate and permanent, unlike dragging to the bin.- OneDrive KFM redirects Desktop/Documents/Pictures into OneDrive — deleting there propagates to all devices. Files On-Demand placeholders (
RECALL_ON_DATA_ACCESS/OFFLINE) are cloud originals; deleting the stub deletes the cloud file. - Controlled Folder Access blocks deletes in Documents/Pictures/Desktop with Access Denied from Defender — distinguish from a real error.
Available MBytesincludes standby/cache — high "available" doesn't mean healthy; commit-limit exhaustion is the real OOM signal.pagefile.sysandswapfile.sysare two different files;C:\Windows\Installer+Package Cachebreak MSI repair if deleted; Prefetch should not be cleaned (myth).- Modern Standby (S0ix) makes wake timers unreliable and hides S3 — normal, not a fault. The task pauses in sleep and catches up on wake via
-StartWhenAvailable. - General rule: if you can't describe a folder in one sentence (especially > 500 MB), don't guess — delegate a
web-searcherlookup before writing the item'sdescription. If you can describe it but it may be user data, still show it in the single final cleanup UI as protected and unchecked by default.
Outcomes scale
A representative recovery on a dev machine that hit ~8 % free under heavy AI/Docker/WSL load:
- Largest single contribution: project build artifacts (
node_modules,bin/obj,target,.next) via Tier 7 purge. - WSL2 / Docker
*.vhdxcompaction (not deletion): often 10–40 GB reclaimed. - Component Store cleanup via DISM after many cumulative updates: a few GB.
- Package-manager caches (npm/pnpm/NuGet/pip/cargo/gradle): a few GB.
Downloadsreview (old installers, ISOs): variable, often 10–20 GB.- Elevated tier (Windows.old after rollback check, Delivery Optimization, exported+cleared logs): variable.
Active alerter installed with a 7-day calibration window; verified via the synthetic disk-trigger test before going live. Numbers scale with workload and disk size — light users see less; heavy AI/Docker/WSL/IDE users see more.
Files (ai-driven-development)
-
assets
-
apply-cleanup-selection.py 20.2 KB
#!/usr/bin/env python3 r""" apply-cleanup-selection.py — the ONLY sanctioned way to apply a cleanup selection produced by render-cleanup-plan.py on Windows 11. Why this exists --------------- The maintaining-windows-health skill splits scan-and-plan from apply on purpose: the user picks items in the HTML UI, the server writes a selection JSON, and THIS script reads that JSON and executes each item's `command`. The skill forbids hand-rolled `Remove-Item` blocks during apply, because they're how you end up deleting items the user explicitly unchecked. The single-source-of-truth is the selection JSON written by the UI server — nothing else. This is a Windows-specific REWRITE of the macOS validator, NOT a translation. The macOS validator's safety rested on POSIX assumptions that are individually FALSE on Windows: a single root `/`, case-sensitivity, one path separator, `~` HOME expansion, no drive letters, no UNC/device paths, no Alternate Data Streams, no 8.3 short names, no non-filesystem providers (HKLM:, Env:, Cert:). A naive "swap the prefixes" port silently fails open. So the validator below is rebuilt around NTFS path canonicalization (`ntpath` mirrors Win32 `GetFullPath` behavior even when this script is unit-tested on macOS). Safety model (what `validate_command` enforces) ----------------------------------------------- 1. Reject command chaining / injection metacharacters outright: ; | & ` $( @( :: > < newline. PowerShell's metacharacter surface is far wider than bash, so a `command` like `docker ps; Remove-Item C:\Windows -Recurse` must never pass just because it starts with a trusted wrapper. 2. Two-tier wrapper model (Windows-specific): - SAFE wrappers (self-policing AND non-destructive): npm/pnpm/yarn/dotnet/ nuget/pip/uv/cargo/go/gradle/mvn/docker/winget/cleanmgr/Clear-RecycleBin… -> trusted, no protection required. - TOOL wrappers (self-policing) whose destructive subcommands are irreversible: vssadmin delete/resize, Dism …/ResetBase, wevtutil cl, pnputil /delete-driver, powercfg /h off, Disable-ComputerRestore -> trusted to target correctly, BUT require `protected:true` + `protected_overrides` because the effect is irreversible. (The macOS model "wrapper == safe" is false on Windows.) 3. Bare delete (Remove-Item/del/rd/rmdir/erase): every path token is env-expanded -> rejected if it is a UNC/device path (\\?\, \\.\, \\server), an 8.3 short name (PROGRA~1), a non-filesystem provider (HKLM:, Env:, …), or carries an Alternate Data Stream (file.txt:hidden) -> canonicalized with ntpath.normpath + trailing dot/space stripping (Win32 behavior) -> required to be drive-absolute (`<letter>:\…`) and never a bare drive root -> classified against an allow-list and a deny-list using LONGEST-PREFIX-WINS so carve-outs resolve correctly (e.g. C:\Windows is denied but C:\Windows\Temp is allowed; C:\Users\me is allowed but C:\Users\me\.ssh is denied). 4. Non-FileSystem PowerShell providers are refused for any delete verb — registry "cleaning" is explicitly unsupported by Microsoft and out of scope. Operations log: Mole-compatible TSV appended to %LOCALAPPDATA%\win-health\operations.log. Usage ----- apply-cleanup-selection.py <selection.json> # apply apply-cleanup-selection.py <selection.json> --dry-run apply-cleanup-selection.py <selection.json> --scan-root D:\projects # repeatable Exit codes ---------- 0 - all items applied (or dry-run completed) 1 - bad input or fatal validation error 2 - one or more items failed (others may have succeeded) """ from __future__ import annotations import argparse import json import ntpath import os import re import shlex import subprocess import sys from datetime import datetime from pathlib import Path # --------------------------------------------------------------------------- # Operations log — %LOCALAPPDATA%\win-health\operations.log (fallback to ~). # --------------------------------------------------------------------------- def _op_log_path() -> Path: base = os.environ.get("LOCALAPPDATA") if base: return Path(base) / "win-health" / "operations.log" return Path.home() / "AppData" / "Local" / "win-health" / "operations.log" # --------------------------------------------------------------------------- # Command classification heads (lowercased). # --------------------------------------------------------------------------- # Self-policing AND non-destructive to user data. Trusted, no protection needed. SAFE_WRAPPER_HEADS = { "npm", "pnpm", "yarn", "bun", "dotnet", "nuget", "pip", "pip3", "uv", "poetry", "pipenv", "cargo", "go", "gradle", "gradlew", "gradlew.bat", "mvn", "maven", "docker", "docker-compose", "winget", "cleanmgr", "cleanmgr.exe", "clear-recyclebin", "delete-deliveryoptimizationcache", "optimize-vhd", "optimize-volume", "brew", # harmless cross-platform passthrough (no-op on Windows) } # Self-policing tools whose *destructive* subcommands are irreversible. # Recognized as wrappers (so they skip bare-path validation) but flagged as # requiring protection when the subcommand is destructive. TOOL_WRAPPER_HEADS = { "dism", "dism.exe", "vssadmin", "wevtutil", "pnputil", "pnputil.exe", "powercfg", "disable-computerrestore", "checkpoint-computer", } # Bare delete verbs / aliases (filesystem). Each gets full path validation. BARE_DELETE_HEADS = { "remove-item", "remove-item.exe", "ri", "rm", "rmdir", "rd", "del", "erase", } # Substrings that, if present anywhere in the command, indicate chaining, # redirection, sub-expression evaluation, .NET static calls, or device/provider # qualifiers. Any of these => reject. (`&&`/`||` are covered by `&`/`|`.) FORBIDDEN_SUBSTRINGS = (";", "|", "&", "`", "$(", "@(", "::", ">", "<", "\n", "\r") # Markers of an irreversible TOOL-wrapper invocation (lowercased command). _DESTRUCTIVE_TOOL_PATTERNS = ( ("vssadmin", "delete"), ("vssadmin", "resize"), ("dism", "/resetbase"), ("wevtutil", "cl "), ("wevtutil", "clear-log"), ("pnputil", "/delete-driver"), ("pnputil", "-d "), ("powercfg", "/h off"), ("powercfg", "/hibernate off"), ("disable-computerrestore", ""), ) # --------------------------------------------------------------------------- # Path helpers (ntpath everywhere so behavior matches Win32 even on macOS CI). # --------------------------------------------------------------------------- def _env(name: str, default: str = "") -> str: return os.environ.get(name, default) def _expandvars(token: str) -> str: """Expand %VAR% / $VAR using ntpath semantics (Windows-style), regardless of the host OS this validator runs on (so unit tests on macOS match).""" return ntpath.expandvars(token) def _norm(path: str) -> str: """Canonicalize like Win32 GetFullPath: unify separators, collapse `..`/`.`, and strip trailing dots/spaces from every component (NTFS truncates them).""" p = path.replace("/", "\\") p = ntpath.normpath(p) drive, rest = ntpath.splitdrive(p) parts = [seg.rstrip(" .") for seg in rest.split("\\")] return drive + "\\".join(parts) def _under(path_low: str, prefix_low: str) -> bool: """True if path_low == prefix_low or is a child of it (component-aware).""" if path_low == prefix_low: return True sep = "" if prefix_low.endswith("\\") else "\\" return path_low.startswith(prefix_low + sep) def _allow_prefixes(scan_roots: list[str]) -> list[str]: sysdrive = _env("SystemDrive", "C:") sysroot = _env("SystemRoot", _env("windir", sysdrive + "\\Windows")) userprofile = _env("USERPROFILE", str(Path.home())) temp = _env("TEMP", _env("TMP", "")) localappdata = _env("LOCALAPPDATA", userprofile + "\\AppData\\Local") prefixes = [ userprofile, # broad: most dev work lives under HOME sysroot + "\\Temp", # C:\Windows\Temp carve-out inside C:\Windows localappdata + "\\Temp", ] if temp: prefixes.append(temp) # Extra scan roots: CLI --scan-root plus WIN_HEALTH_SCAN_ROOTS (`;`-separated). env_roots = _env("WIN_HEALTH_SCAN_ROOTS", "") for r in list(scan_roots) + [x for x in env_roots.split(";") if x.strip()]: prefixes.append(r) out = [] for p in prefixes: if not p: continue out.append(_norm(_expandvars(p)).lower()) return [p for p in out if p] def _deny_prefixes() -> list[str]: sysdrive = _env("SystemDrive", "C:") sysroot = _env("SystemRoot", _env("windir", sysdrive + "\\Windows")) pf = _env("ProgramFiles", sysdrive + "\\Program Files") pfx86 = _env("ProgramFiles(x86)", sysdrive + "\\Program Files (x86)") pf6432 = _env("ProgramW6432", pf) programdata = _env("ProgramData", sysdrive + "\\ProgramData") userprofile = _env("USERPROFILE", str(Path.home())) appdata = _env("APPDATA", userprofile + "\\AppData\\Roaming") localappdata = _env("LOCALAPPDATA", userprofile + "\\AppData\\Local") raw = [ # OS roots (covers System32, SysWOW64, WinSxS, DriverStore, config, # SoftwareDistribution, Installer, Minidump, MEMORY.DMP, Prefetch, …). sysroot, # Program installs. pf, pfx86, pf6432, programdata + "\\Microsoft", programdata + "\\Package Cache", # Boot / recovery / volume metadata. sysdrive + "\\$Recycle.Bin", sysdrive + "\\System Volume Information", sysdrive + "\\Recovery", sysdrive + "\\$WinREAgent", sysdrive + "\\Config.Msi", sysdrive + "\\PerfLogs", sysdrive + "\\Boot", sysdrive + "\\EFI", # Paging / hibernation / crash files (file-level). sysdrive + "\\pagefile.sys", sysdrive + "\\swapfile.sys", sysdrive + "\\hiberfil.sys", sysdrive + "\\DumpStack.log.tmp", # Credential / key stores under HOME (deny beats the broad HOME allow). userprofile + "\\.ssh", userprofile + "\\.gnupg", userprofile + "\\.aws", userprofile + "\\.azure", userprofile + "\\.kube", userprofile + "\\.docker", userprofile + "\\.netrc", userprofile + "\\.git-credentials", appdata + "\\Microsoft\\Crypto", appdata + "\\Microsoft\\Protect", appdata + "\\Microsoft\\Credentials", appdata + "\\Microsoft\\SystemCertificates", appdata + "\\gh\\hosts.yml", localappdata + "\\Microsoft\\Credentials", ] return [_norm(_expandvars(p)).lower() for p in raw] def _classify(path_low: str, allow: list[str], deny: list[str]) -> tuple[str, str]: """Longest-prefix-wins. Returns ('deny'|'allow'|'none', matched_prefix).""" best_allow = max((p for p in allow if _under(path_low, p)), key=len, default=None) best_deny = max((p for p in deny if _under(path_low, p)), key=len, default=None) if best_deny is not None and (best_allow is None or len(best_deny) >= len(best_allow)): return "deny", best_deny if best_allow is not None: return "allow", best_allow return "none", "" def _unquote(tok: str) -> str: if len(tok) >= 2 and tok[0] == tok[-1] and tok[0] in "\"'": return tok[1:-1] return tok def _looks_like_path(tok: str) -> bool: if tok.startswith(("\\\\", "%", "$", "~")): return True if re.match(r"^[A-Za-z][A-Za-z0-9]*:", tok): # drive (1 letter) or provider (more) return True return "\\" in tok or "/" in tok def _validate_path_token(tok: str, allow: list[str], deny: list[str]) -> tuple[bool, str]: expand = _expandvars(tok) if expand.startswith("\\\\"): return False, f"UNC/device path not allowed: {tok!r}" if re.search(r"~\d", expand): return False, f"8.3 short name not allowed: {tok!r}" m = re.match(r"^([A-Za-z][A-Za-z0-9]*):", expand) if m and len(m.group(1)) > 1: return False, f"non-filesystem provider not allowed: {tok!r}" if ":" in expand[2:]: # any colon past the drive letter == Alternate Data Stream return False, f"alternate data stream not allowed: {tok!r}" low = _norm(expand).lower() if not re.match(r"^[a-z]:\\", low): return False, f"path is not drive-absolute: {tok!r} -> {low!r}" if re.match(r"^[a-z]:\\?$", low): return False, f"refuses bare drive root: {low!r}" decision, matched = _classify(low, allow, deny) if decision == "deny": return False, f"targets a protected prefix ({matched!r}): {low!r}" if decision == "none": return False, f"outside allowed zones (HOME/TEMP/scan-roots): {low!r}" return True, "" def _is_destructive_tool(cmd_low: str) -> bool: for head, needle in _DESTRUCTIVE_TOOL_PATTERNS: if cmd_low.startswith(head) and (needle == "" or needle in cmd_low): return True return False def validate_command(command: str, scan_roots: list[str] | None = None ) -> tuple[bool, str, bool]: """Return (ok, reason, requires_protection).""" scan_roots = scan_roots or [] cmd = command.strip() if not cmd: return False, "empty command", False for bad in FORBIDDEN_SUBSTRINGS: if bad in cmd: label = bad.replace("\n", "\\n").replace("\r", "\\r") return False, f"command contains forbidden metacharacter {label!r}", False try: toks = [_unquote(t) for t in shlex.split(cmd, posix=False)] except ValueError as exc: return False, f"unparseable command ({exc})", False if not toks: return False, "no tokens", False head = toks[0].lower() cmd_low = cmd.lower() if head in SAFE_WRAPPER_HEADS: return True, "", False if head in TOOL_WRAPPER_HEADS: return True, "", _is_destructive_tool(cmd_low) if head not in BARE_DELETE_HEADS: return False, f"only delete verbs or vetted wrappers allowed, got {toks[0]!r}", False allow = _allow_prefixes(scan_roots) deny = _deny_prefixes() path_tokens = [t for t in toks[1:] if _looks_like_path(t)] if not path_tokens: return False, "delete command without a target path", False for t in path_tokens: ok, reason = _validate_path_token(t, allow, deny) if not ok: return False, reason, False return True, "", False # --------------------------------------------------------------------------- # Reporting helpers. # --------------------------------------------------------------------------- def human_size(num_bytes: int) -> str: if num_bytes is None or num_bytes == 0: return "0 B" n = float(num_bytes) for unit in ("B", "KB", "MB", "GB", "TB"): if abs(n) < 1024.0: return f"{int(n)} {unit}" if unit == "B" else f"{n:3.1f} {unit}" n /= 1024.0 return f"{n:.1f} PB" def log_op(action: str, path: str, size: str, status: str) -> None: op_log = _op_log_path() op_log.parent.mkdir(parents=True, exist_ok=True) ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") row = f"{ts}\tapply-selection\t{action}\t{path}\t{size}\t{status}\n" with op_log.open("a", encoding="utf-8") as f: f.write(row) def _run(command: str) -> subprocess.CompletedProcess: """Execute one validated command via PowerShell (no profile, non-interactive).""" return subprocess.run( ["powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", command], capture_output=True, text=True, timeout=900, ) def load_selection_json(path: Path) -> dict: """Load a cleanup selection JSON, tolerating a UTF-8 BOM. Windows PowerShell `Set-Content -Encoding UTF8` (and several editors) write a BOM. `json.loads` rejects a leading U+FEFF under encoding=utf-8; utf-8-sig strips it when present and is a no-op for BOM-less files. """ return json.loads(path.read_text(encoding="utf-8-sig")) def main(argv: list[str]) -> int: ap = argparse.ArgumentParser(description="Apply a cleanup selection JSON (Windows)") ap.add_argument("selection", help="path to cleanup-selection-<ts>.json") ap.add_argument("--dry-run", action="store_true", help="print actions, don't execute") ap.add_argument("--scan-root", action="append", default=[], help="additional absolute root where bare deletes are allowed (repeatable)") ap.add_argument("--continue-on-error", action="store_true", help="keep going if an item fails (default: stop on first error)") args = ap.parse_args(argv[1:]) sel_path = Path(args.selection) if not sel_path.is_file(): print(f"selection file not found: {sel_path}", file=sys.stderr) return 1 try: sel = load_selection_json(sel_path) except Exception as exc: print(f"invalid selection JSON: {exc}", file=sys.stderr) return 1 items = sel.get("selected_items", []) or [] if not items: print("selected_items is empty — nothing to do", file=sys.stderr) return 0 protected_overrides = set(sel.get("protected_overrides", []) or []) total_bytes = sum(it.get("size_bytes", 0) or 0 for it in items) print(f"=== Applying selection from {sel_path.name} ===") print(f"Items: {len(items)} | Estimated: {human_size(total_bytes)}") print(f"Protected overrides: {len(protected_overrides)}") print(f"Mode: {'DRY-RUN' if args.dry_run else 'APPLY'}") print() if not args.dry_run: log_op("APPLY-START", "-", "-", "START") ok = skipped = failed = 0 for idx, item in enumerate(items, 1): item_id = item.get("id", "?") label = item.get("label", item_id) path = item.get("path", "") command = item.get("command") size_bytes = item.get("size_bytes", 0) or 0 protected = bool(item.get("protected", False)) prefix = f"[{idx:>2}/{len(items)}]" if not command: print(f"{prefix} SKIP (no command): {label}") log_op("SKIP", path or item_id, human_size(size_bytes), "NO-COMMAND") skipped += 1 continue valid, reason, needs_protection = validate_command(command, args.scan_root) if not valid: print(f"{prefix} SKIP (validation: {reason}): {label}") log_op("SKIP", path or item_id, human_size(size_bytes), f"VALIDATION-FAILED:{reason}") skipped += 1 continue effective_protected = protected or needs_protection if effective_protected and item_id not in protected_overrides: why = "protected" if protected else "irreversible-tool" print(f"{prefix} SKIP ({why}, not overridden): {label}") log_op("SKIP", path or item_id, human_size(size_bytes), "PROTECTED-NOT-OVERRIDDEN") skipped += 1 continue flag = " [OVERRIDE]" if effective_protected else "" print(f"{prefix} {label}{flag} — {human_size(size_bytes)}") print(f" -> {command}") if args.dry_run: ok += 1 continue try: proc = _run(command) if proc.returncode == 0: log_op("PROTECTED-OVERRIDE" if effective_protected else "REMOVED", path or item_id, human_size(size_bytes), "OK") ok += 1 else: stderr_tail = (proc.stderr or "").strip()[-300:] print(f" FAIL exit={proc.returncode}: {stderr_tail}") log_op("REMOVE", path or item_id, human_size(size_bytes), f"FAIL-EXIT-{proc.returncode}") failed += 1 if not args.continue_on_error: print("Stopping on first error. Re-run with --continue-on-error to override.") break except subprocess.TimeoutExpired: print(" TIMEOUT after 900s") log_op("REMOVE", path or item_id, human_size(size_bytes), "TIMEOUT") failed += 1 except Exception as exc: print(f" ERROR: {exc}") log_op("REMOVE", path or item_id, human_size(size_bytes), f"ERROR:{exc}") failed += 1 print() print(f"=== Done: {ok} ok | {skipped} skipped | {failed} failed ===") if not args.dry_run: log_op("APPLY-END", "-", "-", f"OK={ok} SKIP={skipped} FAIL={failed}") return 0 if failed == 0 else 2 if __name__ == "__main__": raise SystemExit(main(sys.argv)) -
Audit-WinHealth.ps1 7.1 KB · in bundle
-
Install-WinHealthCheck.ps1 6.6 KB · in bundle
-
render-cleanup-plan.py 45.1 KB
#!/usr/bin/env python3 """ render-cleanup-plan.py — generate an interactive HTML cleanup-plan UI, serve it on 127.0.0.1, open it in the default browser, and wait for the user's selection. Designed to be invoked by the maintaining-macos-health skill after a scan. Usage: render-cleanup-plan.py <data.json> <data.json> schema: { "baseline": { "container_free_gb": 117.2, "container_total_gb": 460, "container_used_gb": 329, "uptime": "...", "memory_free_pct": 40 }, "categories": [ { "id": "tier-1-3", "title": "Tier 1-3 — Безрисковые кэши", "subtitle": "Регенерируются на следующем билде", "tier": 1, "default_open": true, "items": [ { "id": "uniq-stable-id", "label": "User app cache (mo clean)", "path": "(via mo clean)", "size_bytes": 8723000000, "age_days": null, "kind": "cache", "command": "mo clean --confirm", "protected": false, "warning": null, "default_selected": true }, ... ] }, ... ] } Selection JSON written to <tempdir>/cleanup-selection-<ts>.json on submit (tempdir = %TEMP% on Windows, /tmp on macOS/Linux — tempfile.gettempdir()): { "timestamp": "2026-05-22T18:40:00", "selected_ids": ["...", "..."], "selected_items": [<full item objects>], "totals": {"count": 12, "size_bytes": 25000000000}, "protected_overrides": ["id1", ...] # protected items the user explicitly opted in } Stdout: path to selection JSON when submit succeeds (or "CANCELLED\\n"). Exit code: 0 on submit, 1 on cancel/timeout/error. """ from __future__ import annotations import html import http.server import json import os import socketserver import sys import tempfile import threading import time import webbrowser from datetime import datetime from pathlib import Path from typing import Any PORT = 18347 HOST = "127.0.0.1" SUBMIT_TIMEOUT_SEC = 60 * 60 # 1 hour — user may take their time def human_size(num_bytes: int) -> str: if num_bytes is None: return "—" for unit in ("B", "KB", "MB", "GB", "TB"): if abs(num_bytes) < 1024.0: return f"{num_bytes:3.1f} {unit}" if unit != "B" else f"{int(num_bytes)} {unit}" num_bytes /= 1024.0 return f"{num_bytes:.1f} PB" def size_class(num_bytes: int) -> str: if num_bytes is None: return "size-unknown" gb = num_bytes / (1024**3) if gb >= 5: return "size-xl" if gb >= 1: return "size-lg" if gb >= 0.1: return "size-md" return "size-sm" def render_html(data: dict[str, Any]) -> str: baseline = data.get("baseline", {}) container_free_gb = baseline.get("container_free_gb") container_total_gb = baseline.get("container_total_gb") container_used_gb = baseline.get("container_used_gb") pct_free = (container_free_gb / container_total_gb * 100) if container_total_gb else 0 categories_html_parts: list[str] = [] total_candidates_bytes = 0 total_items_count = 0 for cat in data.get("categories", []): cat_id = html.escape(cat["id"]) cat_title = html.escape(cat["title"]) cat_subtitle = html.escape(cat.get("subtitle", "")) default_open = "open" if cat.get("default_open", False) else "" tier = cat.get("tier", "") items_html: list[str] = [] cat_bytes = 0 cat_count = 0 # Sort items inside each category by size descending — largest first. sorted_items = sorted( cat.get("items", []), key=lambda it: -(it.get("size_bytes") or 0), ) for item in sorted_items: item_id = html.escape(item["id"]) label = html.escape(item["label"]) path = html.escape(item.get("path", "")) size_bytes = item.get("size_bytes", 0) or 0 age_days = item.get("age_days") kind = html.escape(item.get("kind", "")) command = item.get("command") protected = bool(item.get("protected", False)) warning = item.get("warning") default_selected = bool(item.get("default_selected", not protected)) cat_bytes += size_bytes cat_count += 1 total_candidates_bytes += size_bytes total_items_count += 1 sz_label = human_size(size_bytes) sz_cls = size_class(size_bytes) age_label = f"{age_days}d" if age_days is not None else "" row_classes = ["item-row"] if protected: row_classes.append("protected") checked = "checked" if default_selected else "" data_attrs = ( f'data-id="{item_id}" ' f'data-size="{size_bytes}" ' f'data-protected="{int(protected)}" ' ) warning_attr = ( f' data-warning="{html.escape(warning, quote=True)}"' if warning else "" ) # Build a structured tooltip payload (JSON-encoded into a data attribute) tooltip_payload = { "label": item["label"], "description": item.get("description", ""), "path": item.get("path", ""), "size": sz_label, "kind": kind, "age": (f"{age_days} days" if age_days is not None else None), "command": command, "warning": warning, "protected": protected, } tooltip_attr = html.escape(json.dumps(tooltip_payload, ensure_ascii=False), quote=True) badge_html = ( '<span class="badge badge-protected" aria-label="protected">🔒</span>' if protected else "" ) items_html.append( f''' <label class="{' '.join(row_classes)}" data-tooltip="{tooltip_attr}"{warning_attr}> <input type="checkbox" class="item-cb" {checked} {data_attrs}> <span class="size {sz_cls}">{sz_label}</span> <span class="age">{age_label}</span> <span class="kind">{kind}</span> <div class="item-main"> <div class="label">{label}{badge_html}</div> <div class="path">{path}</div> </div> </label> ''' ) category_total = human_size(cat_bytes) tier_str = str(tier) if tier_str in ("1-3",): tier_cls = "cat-tier-safe" elif tier_str in ("7", "5", "8"): tier_cls = "cat-tier-medium" elif tier_str == "10": tier_cls = "cat-tier-careful" elif tier_str == "P": tier_cls = "cat-tier-protected" else: tier_cls = "" categories_html_parts.append( f''' <details class="category" {default_open} data-cat="{cat_id}" data-cat-total-bytes="{cat_bytes}" data-cat-total-count="{cat_count}"> <summary> <span class="cat-toggle">▸</span> <span class="cat-tier {tier_cls}">T{tier}</span> <span class="cat-title">{cat_title}</span> <span class="cat-subtitle">{cat_subtitle}</span> <span class="cat-stats"> <span class="cat-sel-size">0 B</span><span class="sep">/</span><span class="cat-total-size">{category_total}</span> <span class="dot">·</span> <span class="cat-sel-count">0</span><span class="sep">/</span><span class="cat-total-count">{cat_count}</span> items </span> <label class="select-all-wrap" onclick="event.stopPropagation()"> <input type="checkbox" class="select-all" data-cat="{cat_id}"> <span class="select-all-label">all</span> </label> </summary> <div class="category-body"> {''.join(items_html)} </div> </details> ''' ) categories_html = "\n".join(categories_html_parts) total_candidates_label = human_size(total_candidates_bytes) generated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S") container_free_bytes = int((container_free_gb or 0) * (1024 ** 3)) container_total_bytes = int((container_total_gb or 0) * (1024 ** 3)) return HTML_TEMPLATE.format( container_free_gb=f"{container_free_gb:.1f}" if container_free_gb else "—", container_total_gb=f"{container_total_gb:.0f}" if container_total_gb else "—", container_used_gb=f"{container_used_gb:.0f}" if container_used_gb else "—", container_free_bytes=container_free_bytes, container_total_bytes=container_total_bytes, pct_free=f"{pct_free:.0f}", total_candidates_label=total_candidates_label, total_items_count=total_items_count, categories_html=categories_html, generated_at=generated_at, ) HTML_TEMPLATE = r"""<!doctype html> <html lang="en" data-theme="auto"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width,initial-scale=1"> <title>macOS cleanup plan</title> <style> :root {{ --bg: #fafaf9; --fg: #1c1917; --muted: #78716c; --card: #ffffff; --border: #e7e5e4; --border-strong: #d6d3d1; --accent: #2563eb; --accent-fg: #ffffff; --warn: #c2410c; --warn-bg: #fff7ed; --danger: #b91c1c; --ok: #15803d; --shadow: 0 1px 2px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.04); --size-sm: #16a34a; --size-md: #ca8a04; --size-lg: #ea580c; --size-xl: #dc2626; }} @media (prefers-color-scheme: dark) {{ :root {{ --bg: #18181b; --fg: #f4f4f5; --muted: #a1a1aa; --card: #27272a; --border: #3f3f46; --border-strong: #52525b; --accent: #60a5fa; --accent-fg: #0c0a09; --warn: #fb923c; --warn-bg: #431407; --danger: #f87171; --ok: #4ade80; --shadow: 0 1px 2px rgba(0,0,0,0.4), 0 4px 12px rgba(0,0,0,0.3); }} }} * {{ box-sizing: border-box; }} html, body {{ margin: 0; padding: 0; background: var(--bg); color: var(--fg); }} body {{ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; font-size: 14px; line-height: 1.5; padding-bottom: 120px; }} header {{ position: sticky; top: 0; z-index: 10; background: var(--bg); border-bottom: 1px solid var(--border); padding: 16px 24px; backdrop-filter: blur(8px); }} header h1 {{ font-size: 18px; margin: 0 0 6px 0; font-weight: 600; letter-spacing: -0.01em; }} header .meta {{ display: flex; gap: 16px; flex-wrap: wrap; font-size: 12px; color: var(--muted); }} header .meta strong {{ color: var(--fg); font-weight: 600; }} .container {{ max-width: 1100px; margin: 0 auto; padding: 24px; }} .category {{ background: var(--card); border: 1px solid var(--border); border-left: 3px solid var(--border); border-radius: 10px; margin-bottom: 14px; box-shadow: var(--shadow); overflow: hidden; transition: border-left-color 0.15s; }} .category.has-selection {{ border-left-color: var(--accent); }} .category summary {{ display: grid; grid-template-columns: 18px 56px minmax(180px, max-content) 1fr auto auto; grid-template-rows: auto auto; column-gap: 14px; row-gap: 4px; align-items: center; padding: 14px 18px; cursor: pointer; list-style: none; user-select: none; }} .category summary .cat-toggle, .category summary .cat-tier, .category summary .cat-title, .category summary .cat-stats, .category summary .select-all-wrap {{ grid-row: 1; }} .category summary .cat-subtitle {{ grid-column: 3 / -1; grid-row: 2; margin-top: -2px; }} .category summary::-webkit-details-marker {{ display: none; }} .cat-toggle {{ display: inline-flex; align-items: center; justify-content: center; transition: transform 0.15s; color: var(--fg); font-size: 14px; width: 18px; line-height: 1; }} .category[open] .cat-toggle {{ transform: rotate(90deg); }} .cat-tier {{ display: inline-flex; align-items: center; justify-content: center; min-width: 52px; height: 22px; padding: 0 10px; border-radius: 6px; background: var(--border); color: var(--muted); font-size: 11px; font-weight: 700; letter-spacing: 0.02em; white-space: nowrap; }} .cat-tier-safe {{ background: rgba(34,197,94,0.18); color: var(--ok); }} .cat-tier-medium {{ background: rgba(37,99,235,0.18); color: var(--accent); }} .cat-tier-careful {{ background: rgba(194,65,12,0.22); color: var(--warn); }} .cat-tier-protected {{ background: rgba(185,28,28,0.22); color: var(--danger); }} .cat-title {{ font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }} .cat-subtitle {{ color: var(--muted); font-size: 12px; font-weight: 400; white-space: normal; line-height: 1.4; }} .cat-stats {{ color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; display: inline-flex; align-items: baseline; gap: 4px; }} .cat-stats .cat-sel-size {{ color: var(--accent); font-weight: 700; font-size: 13px; }} .cat-stats .cat-total-size, .cat-stats .cat-sel-count, .cat-stats .cat-total-count {{ color: var(--fg); font-weight: 500; }} .cat-stats .sep, .cat-stats .dot {{ color: var(--muted); margin: 0 2px; }} .category.empty-selection .cat-sel-size {{ color: var(--muted); font-weight: 500; }} .select-all-wrap {{ display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 6px; background: var(--bg); font-size: 11px; color: var(--muted); cursor: pointer; white-space: nowrap; }} .select-all-wrap input {{ margin: 0; cursor: pointer; }} .actions .select-all-wrap {{ padding: 10px 14px; border: 1px solid var(--border-strong); color: var(--fg); font-weight: 500; }} .category-body {{ border-top: 1px solid var(--border); padding: 4px 0; }} .item-row {{ display: grid; grid-template-columns: 22px 78px 44px 110px 1fr; column-gap: 12px; align-items: center; padding: 8px 18px; cursor: pointer; border-radius: 6px; margin: 2px 8px; transition: background 0.1s; min-height: 44px; }} .item-row:hover {{ background: var(--bg); }} .item-row.protected {{ opacity: 0.6; }} .item-row.protected:hover {{ opacity: 0.95; }} .item-row input[type="checkbox"] {{ margin: 0; cursor: pointer; transform: scale(1.1); }} .item-row .size {{ font-variant-numeric: tabular-nums; font-weight: 600; text-align: right; font-size: 12px; }} .size-sm {{ color: var(--size-sm); }} .size-md {{ color: var(--size-md); }} .size-lg {{ color: var(--size-lg); }} .size-xl {{ color: var(--size-xl); }} .size-unknown {{ color: var(--muted); }} .item-row .age {{ font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; text-align: right; }} .item-row .kind {{ font-size: 11px; color: var(--muted); padding: 2px 6px; background: var(--bg); border-radius: 4px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }} .item-main {{ min-width: 0; display: flex; flex-direction: column; gap: 2px; }} .item-main .label {{ font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: flex; align-items: center; gap: 6px; }} .item-main .path {{ font-family: "SF Mono", "Menlo", "Consolas", monospace; font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: ltr; }} .badge {{ display: inline-flex; align-items: center; font-size: 10px; padding: 1px 5px; border-radius: 4px; font-weight: 600; letter-spacing: 0.02em; line-height: 1; }} .badge-protected {{ background: var(--warn-bg); color: var(--warn); border: 1px solid var(--warn); }} footer {{ position: fixed; bottom: 0; left: 0; right: 0; background: var(--card); border-top: 1px solid var(--border-strong); padding: 14px 24px; box-shadow: 0 -4px 12px rgba(0,0,0,0.06); z-index: 100; }} footer .inner {{ max-width: 1100px; margin: 0 auto; display: flex; justify-content: space-between; align-items: center; gap: 16px; }} .summary-stats {{ display: flex; gap: 28px; align-items: baseline; }} .summary-stats .big {{ font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }} .summary-stats .big.accent {{ color: var(--accent); }} .summary-stats .label-sub {{ color: var(--muted); font-size: 12px; margin-left: 4px; }} .summary-stats .muted-dot {{ margin: 0 4px; }} .summary-stats .after-preview {{ display: flex; align-items: baseline; gap: 4px; padding: 6px 14px; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; }} .summary-stats .after-preview .big {{ color: var(--ok); font-size: 20px; }} .summary-stats .protected-count {{ color: var(--warn); }} .actions button {{ font: inherit; padding: 10px 18px; border: 1px solid var(--border-strong); border-radius: 8px; background: var(--card); color: var(--fg); cursor: pointer; font-weight: 500; transition: all 0.1s; }} .actions button:hover {{ background: var(--bg); }} .actions button.primary {{ background: var(--accent); color: var(--accent-fg); border-color: var(--accent); font-weight: 600; margin-left: 8px; }} .actions button.primary:hover {{ filter: brightness(1.1); }} .actions button:disabled {{ opacity: 0.5; cursor: not-allowed; }} #done-screen {{ display: none; position: fixed; inset: 0; background: var(--bg); align-items: center; justify-content: center; flex-direction: column; gap: 16px; text-align: center; padding: 24px; z-index: 1000; }} #done-screen.shown {{ display: flex; }} #done-screen .check {{ font-size: 48px; width: 80px; height: 80px; border-radius: 50%; background: var(--ok); color: white; display: flex; align-items: center; justify-content: center; font-weight: bold; }} #done-screen h2 {{ margin: 0; font-size: 22px; }} #done-screen p {{ color: var(--muted); margin: 0; max-width: 480px; }} #done-screen .done-stats {{ display: flex; gap: 28px; align-items: baseline; background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 16px 22px; box-shadow: var(--shadow); }} #done-screen .done-stats .stat-block {{ display: flex; flex-direction: column; align-items: flex-start; gap: 2px; }} #done-screen .done-stats .stat-num {{ font-size: 22px; font-weight: 700; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }} #done-screen .done-stats .stat-num.accent {{ color: var(--accent); }} #done-screen .done-stats .stat-num.ok {{ color: var(--ok); }} #done-screen .done-stats .stat-label {{ color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; }} #done-screen code {{ background: var(--card); padding: 4px 8px; border-radius: 4px; font-family: "SF Mono", monospace; font-size: 12px; }} .toast {{ position: fixed; top: 24px; right: 24px; background: var(--danger); color: white; padding: 12px 16px; border-radius: 8px; box-shadow: var(--shadow); z-index: 200; opacity: 0; transform: translateY(-10px); transition: all 0.2s; pointer-events: none; }} .toast.shown {{ opacity: 1; transform: translateY(0); }} #tt {{ position: fixed; z-index: 200; max-width: 520px; min-width: 280px; background: var(--card); color: var(--fg); border: 1px solid var(--border-strong); border-radius: 8px; padding: 12px 14px; box-shadow: 0 8px 24px rgba(0,0,0,0.18), 0 2px 6px rgba(0,0,0,0.12); font-size: 12px; pointer-events: none; opacity: 0; transition: opacity 0.08s ease-out; display: none; }} #tt.shown {{ opacity: 1; display: block; }} #tt .tt-title {{ font-weight: 600; font-size: 13px; margin: 0 0 6px 0; letter-spacing: -0.01em; }} #tt .tt-desc {{ color: var(--fg); font-size: 12px; line-height: 1.5; margin: 0 0 10px 0; padding: 8px 10px; background: var(--bg); border-radius: 6px; border-left: 3px solid var(--accent); }} #tt .tt-row {{ display: grid; grid-template-columns: 78px 1fr; gap: 8px; align-items: baseline; padding: 2px 0; }} #tt .tt-label {{ color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; }} #tt .tt-value {{ font-family: "SF Mono", "Menlo", "Consolas", monospace; font-size: 11.5px; word-break: break-all; white-space: pre-wrap; }} #tt .tt-value.tt-mono-strong {{ color: var(--fg); font-weight: 500; }} #tt .tt-warning {{ margin-top: 8px; padding: 8px 10px; border-radius: 6px; background: var(--warn-bg); color: var(--warn); border: 1px solid var(--warn); font-size: 11.5px; line-height: 1.45; }} #tt .tt-protected {{ margin-top: 8px; padding: 6px 10px; border-radius: 6px; background: var(--warn-bg); color: var(--warn); font-weight: 600; font-size: 11px; letter-spacing: 0.02em; }} @media (max-width: 720px) {{ .item-row {{ grid-template-columns: 22px 60px 1fr; }} .item-row .age, .item-row .kind {{ display: none; }} .item-main .path {{ display: none; }} .category summary {{ grid-template-columns: 18px 52px 1fr auto; }} .cat-subtitle, .cat-stats {{ display: none; }} }} @media (prefers-reduced-motion: reduce) {{ *, *::before, *::after {{ transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; }} }} </style> </head> <body> <header> <h1>🧹 Windows cleanup plan</h1> <div class="meta"> <span><strong>{container_free_gb} GB</strong> free <span class="label-sub">/ {container_total_gb} GB · {pct_free}%</span></span> <span><strong>{container_used_gb} GB</strong> used</span> <span><strong>{total_items_count}</strong> candidate items · <strong>{total_candidates_label}</strong> total</span> <span class="label-sub">Generated {generated_at}</span> </div> </header> <main class="container"> {categories_html} </main> <footer> <div class="inner"> <div class="summary-stats"> <div> <span class="big accent" id="sel-size">0 B</span> <span class="label-sub">selected</span> <span class="label-sub muted-dot">·</span> <span class="label-sub"><span id="sel-count">0</span> items</span> </div> <div class="after-preview" id="after-preview"> <span class="label-sub">after cleanup →</span> <span class="big" id="after-free">{container_free_gb} GB</span> <span class="label-sub">free (<span id="after-pct">{pct_free}</span>%)</span> </div> <div id="protected-warning" style="display:none"> <span class="big protected-count" id="sel-protected">0</span> <span class="label-sub">protected ⚠</span> </div> </div> <div class="actions"> <label class="select-all-wrap"> <input type="checkbox" id="select-all-global"> <span class="select-all-label">all files</span> </label> <button id="cancel-btn" type="button">Cancel</button> <button id="submit-btn" class="primary" type="button" disabled>Submit plan →</button> </div> </div> </footer> <div id="done-screen"> <div class="check">✓</div> <h2>Plan submitted</h2> <p id="done-summary">You can close this window. Return to the terminal — the agent will show your selection and ask for confirmation before applying any changes.</p> <div id="done-stats" class="done-stats"></div> <code id="done-path"></code> </div> <div class="toast" id="toast"></div> <div id="tt" role="tooltip"></div> <script> const allItems = document.querySelectorAll('.item-cb'); const selSize = document.getElementById('sel-size'); const selCount = document.getElementById('sel-count'); const selProtected = document.getElementById('sel-protected'); const protectedWarning = document.getElementById('protected-warning'); const selectAllGlobal = document.getElementById('select-all-global'); const submitBtn = document.getElementById('submit-btn'); const cancelBtn = document.getElementById('cancel-btn'); const doneScreen = document.getElementById('done-screen'); const donePath = document.getElementById('done-path'); const toast = document.getElementById('toast'); function humanSize(bytes) {{ const units = ['B', 'KB', 'MB', 'GB', 'TB']; let n = bytes, i = 0; while (n >= 1024 && i < units.length - 1) {{ n /= 1024; i++; }} return (i === 0 ? n.toFixed(0) : n.toFixed(1)) + ' ' + units[i]; }} function showToast(msg, ms) {{ toast.textContent = msg; toast.classList.add('shown'); setTimeout(() => toast.classList.remove('shown'), ms || 3000); }} const BASELINE_FREE_BYTES = {container_free_bytes}; const BASELINE_TOTAL_BYTES = {container_total_bytes}; const afterFree = document.getElementById('after-free'); const afterPct = document.getElementById('after-pct'); function updateTotals() {{ let totalBytes = 0, count = 0, protectedCount = 0; allItems.forEach(cb => {{ if (cb.checked) {{ count++; totalBytes += parseInt(cb.dataset.size, 10) || 0; if (cb.dataset.protected === '1') protectedCount++; }} }}); selSize.textContent = humanSize(totalBytes); selCount.textContent = count; submitBtn.disabled = count === 0; // After-cleanup preview: free + selected, capped at total const afterBytes = Math.min(BASELINE_FREE_BYTES + totalBytes, BASELINE_TOTAL_BYTES); const afterGb = afterBytes / (1024 ** 3); const afterPctVal = (afterBytes / BASELINE_TOTAL_BYTES) * 100; if (afterFree) afterFree.textContent = afterGb.toFixed(1) + ' GB'; if (afterPct) afterPct.textContent = afterPctVal.toFixed(0); if (protectedCount > 0) {{ protectedWarning.style.display = ''; selProtected.textContent = protectedCount; }} else {{ protectedWarning.style.display = 'none'; }} // category counters + per-category live size document.querySelectorAll('.category').forEach(cat => {{ const items = cat.querySelectorAll('.item-cb'); const checked = cat.querySelectorAll('.item-cb:checked'); let catBytes = 0; checked.forEach(cb => {{ catBytes += parseInt(cb.dataset.size, 10) || 0; }}); const selSizeEl = cat.querySelector('.cat-sel-size'); const selCountEl = cat.querySelector('.cat-sel-count'); if (selSizeEl) selSizeEl.textContent = humanSize(catBytes); if (selCountEl) selCountEl.textContent = checked.length; cat.classList.toggle('empty-selection', checked.length === 0); cat.classList.toggle('has-selection', checked.length > 0); const selectAll = cat.querySelector('.select-all'); if (selectAll) {{ selectAll.checked = items.length > 0 && items.length === checked.length; selectAll.indeterminate = checked.length > 0 && checked.length < items.length; }} }}); if (selectAllGlobal) {{ selectAllGlobal.checked = allItems.length > 0 && count === allItems.length; selectAllGlobal.indeterminate = count > 0 && count < allItems.length; }} }} // Esc → cancel (peak-end / keyboard-friendly) document.addEventListener('keydown', (ev) => {{ if (ev.key === 'Escape' && !doneScreen.classList.contains('shown')) {{ ev.preventDefault(); cancelBtn.click(); }} }}); allItems.forEach(cb => {{ cb.addEventListener('change', (ev) => {{ const row = cb.closest('.item-row'); if (cb.checked && cb.dataset.protected === '1') {{ const warning = row.dataset.warning || 'This item is protected. Deleting it can lose user data or break apps.'; if (!confirm('⚠️ PROTECTED ITEM\\n\\n' + warning + '\\n\\nAre you sure you want to include this?')) {{ cb.checked = false; }} }} updateTotals(); }}); }}); function confirmProtectedBulk(scopeLabel, protectedCount) {{ if (protectedCount === 0) return true; return confirm( '⚠️ PROTECTED ITEMS\\n\\n' + scopeLabel + ' includes ' + protectedCount + ' protected item(s). ' + 'These may include user data, synced files, dumps, models, VM disks, or irreversible system actions.\\n\\n' + 'Select them anyway?' ); }} function setItemsChecked(items, checked, scopeLabel) {{ const itemList = Array.from(items); const protectedCount = itemList.filter(cb => cb.dataset.protected === '1').length; if (checked && !confirmProtectedBulk(scopeLabel, protectedCount)) {{ return false; }} itemList.forEach(cb => {{ cb.checked = checked; }}); updateTotals(); if (checked && protectedCount > 0) {{ showToast('Selected ' + protectedCount + ' protected item(s). Review before Submit.', 5000); }} return true; }} document.querySelectorAll('.select-all').forEach(sa => {{ sa.addEventListener('change', (ev) => {{ ev.stopPropagation(); const catId = sa.dataset.cat; const cat = document.querySelector('.category[data-cat="' + catId + '"]'); const items = cat.querySelectorAll('.item-cb'); if (!setItemsChecked(items, sa.checked, 'This category')) {{ updateTotals(); }} }}); }}); selectAllGlobal.addEventListener('change', () => {{ if (!setItemsChecked(allItems, selectAllGlobal.checked, 'The full cleanup plan')) {{ updateTotals(); }} }}); cancelBtn.addEventListener('click', async () => {{ if (!confirm('Cancel and close? No cleanup will be performed.')) return; try {{ await fetch('/cancel', {{ method: 'POST' }}); }} catch (e) {{}} doneScreen.querySelector('.check').textContent = '×'; doneScreen.querySelector('.check').style.background = 'var(--muted)'; doneScreen.querySelector('h2').textContent = 'Cancelled'; doneScreen.querySelector('p').textContent = 'No changes were made. You can close this window.'; donePath.style.display = 'none'; doneScreen.classList.add('shown'); }}); submitBtn.addEventListener('click', async () => {{ const selected = []; const protectedOverrides = []; let totalBytes = 0; allItems.forEach(cb => {{ if (cb.checked) {{ selected.push(cb.dataset.id); totalBytes += parseInt(cb.dataset.size, 10) || 0; if (cb.dataset.protected === '1') protectedOverrides.push(cb.dataset.id); }} }}); if (selected.length === 0) {{ showToast('Nothing selected.'); return; }} const payload = {{ selected_ids: selected, protected_overrides: protectedOverrides, totals: {{ count: selected.length, size_bytes: totalBytes }} }}; submitBtn.disabled = true; submitBtn.textContent = 'Sending…'; try {{ const resp = await fetch('/submit', {{ method: 'POST', headers: {{ 'Content-Type': 'application/json' }}, body: JSON.stringify(payload) }}); if (!resp.ok) throw new Error('HTTP ' + resp.status); const result = await resp.json(); donePath.textContent = result.path || ''; const afterBytes = Math.min(BASELINE_FREE_BYTES + totalBytes, BASELINE_TOTAL_BYTES); const afterGb = (afterBytes / (1024 ** 3)).toFixed(1); const afterPctVal = ((afterBytes / BASELINE_TOTAL_BYTES) * 100).toFixed(0); document.getElementById('done-stats').innerHTML = '<div class="stat-block"><span class="stat-num accent">' + humanSize(totalBytes) + '</span><span class="stat-label">to be freed</span></div>' + '<div class="stat-block"><span class="stat-num">' + selected.length + '</span><span class="stat-label">items</span></div>' + '<div class="stat-block"><span class="stat-num ok">' + afterGb + ' GB</span><span class="stat-label">free after (' + afterPctVal + '%)</span></div>' + (protectedOverrides.length > 0 ? '<div class="stat-block"><span class="stat-num" style="color:var(--warn)">' + protectedOverrides.length + ' ⚠</span><span class="stat-label">protected overrides</span></div>' : ''); doneScreen.classList.add('shown'); }} catch (e) {{ showToast('Submit failed: ' + e.message, 6000); submitBtn.disabled = false; submitBtn.textContent = 'Submit plan →'; }} }}); updateTotals(); // ----------------------------------------------------------------------- // Custom tooltip — multi-line, structured, instant // ----------------------------------------------------------------------- const tt = document.getElementById('tt'); function escapeHtml(s) {{ return String(s == null ? '' : s) .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') .replace(/"/g, '"').replace(/'/g, '''); }} function renderTooltip(payload) {{ const descHtml = payload.description ? '<div class="tt-desc">' + escapeHtml(payload.description) + '</div>' : ''; const rows = []; if (payload.path) rows.push(['Path', payload.path, 'tt-mono-strong']); if (payload.kind) rows.push(['Kind', payload.kind]); if (payload.size) rows.push(['Size', payload.size]); if (payload.age) rows.push(['Modified', payload.age + ' ago']); if (payload.command) rows.push(['Command', payload.command, 'tt-mono-strong']); const rowsHtml = rows.map(([l, v, cls]) => '<div class="tt-row"><div class="tt-label">' + escapeHtml(l) + '</div><div class="tt-value ' + (cls || '') + '">' + escapeHtml(v) + '</div></div>' ).join(''); const warnHtml = payload.warning ? '<div class="tt-warning">⚠ ' + escapeHtml(payload.warning) + '</div>' : ''; const protHtml = payload.protected ? '<div class="tt-protected">🔒 PROTECTED — checking requires explicit confirm</div>' : ''; return '<div class="tt-title">' + escapeHtml(payload.label || '') + '</div>' + descHtml + rowsHtml + warnHtml + protHtml; }} function positionTooltip(clientX, clientY) {{ const margin = 14; const rect = tt.getBoundingClientRect(); const vw = window.innerWidth, vh = window.innerHeight; let x = clientX + 18; let y = clientY + 18; if (x + rect.width + margin > vw) x = Math.max(margin, clientX - rect.width - 18); if (y + rect.height + margin > vh) y = Math.max(margin, clientY - rect.height - 18); tt.style.left = x + 'px'; tt.style.top = y + 'px'; }} function showTooltipFromEvent(target, ev) {{ const raw = target.getAttribute('data-tooltip'); if (!raw) return; let payload; try {{ payload = JSON.parse(raw); }} catch (e) {{ return; }} tt.innerHTML = renderTooltip(payload); tt.classList.add('shown'); positionTooltip(ev.clientX, ev.clientY); }} function hideTooltip() {{ tt.classList.remove('shown'); }} document.addEventListener('mouseover', (ev) => {{ const target = ev.target.closest('[data-tooltip]'); if (target) showTooltipFromEvent(target, ev); }}); document.addEventListener('mousemove', (ev) => {{ if (!tt.classList.contains('shown')) return; const target = ev.target.closest('[data-tooltip]'); if (!target) {{ hideTooltip(); return; }} positionTooltip(ev.clientX, ev.clientY); }}); document.addEventListener('mouseout', (ev) => {{ if (!ev.relatedTarget || !ev.relatedTarget.closest('[data-tooltip]')) hideTooltip(); }}); document.addEventListener('scroll', hideTooltip, true); </script> </body> </html> """ class CleanupServer(socketserver.ThreadingMixIn, http.server.HTTPServer): """ThreadingMixIn so requests don't block each other; daemon_threads so the process can exit cleanly even if a request thread is mid-flight.""" allow_reuse_address = True daemon_threads = True def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.submission: dict | None = None self.cancelled = False self.shutdown_event = threading.Event() def make_handler(html_content: str, data_categories: list[dict]): # index items by id for resolving selection back to full objects id_to_item: dict[str, dict] = {} id_to_category: dict[str, str] = {} for cat in data_categories: for item in cat.get("items", []): id_to_item[item["id"]] = item id_to_category[item["id"]] = cat["id"] class Handler(http.server.BaseHTTPRequestHandler): def log_message(self, fmt, *args): pass # silence stderr def do_GET(self): if self.path in ("/", "/index.html"): body = html_content.encode("utf-8") self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) elif self.path == "/health": self.send_response(200) self.send_header("Content-Type", "text/plain") self.end_headers() self.wfile.write(b"ok") else: self.send_error(404) def do_POST(self): try: length = int(self.headers.get("Content-Length", "0")) raw = self.rfile.read(length) if length else b"" except Exception as exc: self.send_error(400, f"bad request: {exc}") return if self.path == "/cancel": self.server.cancelled = True try: self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Connection", "close") self.end_headers() self.wfile.write(b'{"ok":true}') self.wfile.flush() except Exception: pass # Signal main thread to exit. ThreadingMixIn + daemon_threads means # serve_forever() is still running on another thread; setting the event # lets main wake up, call server.shutdown(), and the loop ends cleanly. self.server.shutdown_event.set() return if self.path == "/submit": print(f"[render-cleanup-plan] received /submit ({len(raw)} bytes)", file=sys.stderr) try: payload = json.loads(raw.decode("utf-8")) if raw else {} except Exception as exc: self.send_error(400, f"bad json: {exc}") return try: selected_ids = payload.get("selected_ids", []) or [] protected_overrides = payload.get("protected_overrides", []) or [] selected_items = [ {**id_to_item[i], "category_id": id_to_category.get(i, "")} for i in selected_ids if i in id_to_item ] total_bytes = sum(it.get("size_bytes", 0) or 0 for it in selected_items) selection = { "timestamp": datetime.now().isoformat(), "selected_ids": selected_ids, "selected_items": selected_items, "protected_overrides": protected_overrides, "totals": {"count": len(selected_items), "size_bytes": total_bytes}, } ts = datetime.now().strftime("%Y%m%d-%H%M%S") # Cross-platform temp dir: /tmp on macOS/Linux, %TEMP% on Windows. out_path = Path(tempfile.gettempdir()) / f"cleanup-selection-{ts}.json" out_path.write_text(json.dumps(selection, indent=2, ensure_ascii=False), encoding="utf-8") self.server.submission = {"selection": selection, "path": str(out_path)} body = json.dumps({"ok": True, "path": str(out_path)}).encode("utf-8") self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.send_header("Connection", "close") self.end_headers() self.wfile.write(body) self.wfile.flush() except Exception as exc: print(f"[render-cleanup-plan] /submit handler error: {exc}", file=sys.stderr) try: self.send_error(500, f"server error: {exc}") except Exception: pass return # Now signal main to exit. No sleep needed — ThreadingMixIn means the # response has been written and flushed before this point. self.server.shutdown_event.set() return self.send_error(404) return Handler def main(argv: list[str]) -> int: if len(argv) < 2: print("usage: render-cleanup-plan.py <data.json>", file=sys.stderr) return 2 data_path = Path(argv[1]) if not data_path.is_file(): print(f"data file not found: {data_path}", file=sys.stderr) return 2 data = json.loads(data_path.read_text(encoding="utf-8-sig")) html_content = render_html(data) handler = make_handler(html_content, data.get("categories", [])) try: server = CleanupServer((HOST, PORT), handler) except OSError as exc: print(f"failed to bind {HOST}:{PORT}: {exc}", file=sys.stderr) return 1 server_thread = threading.Thread(target=server.serve_forever, daemon=True) server_thread.start() url = f"http://{HOST}:{PORT}/" # eprint, not stdout — stdout is reserved for the final selection path print(f"[render-cleanup-plan] serving at {url}", file=sys.stderr) print(f"[render-cleanup-plan] waiting for user selection (timeout {SUBMIT_TIMEOUT_SEC}s)…", file=sys.stderr) # macOS: `open` works for arbitrary URLs and respects the user's default browser try: if sys.platform == "darwin": os.system(f"open '{url}'") else: webbrowser.open(url) except Exception: pass finished = server.shutdown_event.wait(timeout=SUBMIT_TIMEOUT_SEC) print("[render-cleanup-plan] shutdown_event received, stopping server", file=sys.stderr) try: server.shutdown() except Exception as exc: print(f"[render-cleanup-plan] server.shutdown() error: {exc}", file=sys.stderr) try: server.server_close() except Exception: pass server_thread.join(timeout=2) if not finished: print("TIMEOUT", file=sys.stderr) return 1 if server.cancelled: print("CANCELLED") return 1 if server.submission: print(server.submission["path"]) return 0 print("NO_SUBMISSION", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main(sys.argv)) -
test_validate_command.py 10 KB
#!/usr/bin/env python3 """ test_validate_command.py — adversarial unit tests for the apply-script validator. This is the SAFETY-CORE GATE of maintaining-windows-health. It is pure string/path logic (ntpath-based), so it runs and fully verifies the Windows validator on ANY OS — including the macOS machine the skill is authored on, where no NTFS volume exists. Run it before trusting the apply script: python3 test_validate_command.py Exit code 0 = all cases pass; 1 = at least one regression. """ from __future__ import annotations import importlib.util import os import sys from pathlib import Path HERE = Path(__file__).resolve().parent # Synthetic Windows environment so the validator's allow/deny prefixes resolve # deterministically regardless of host OS. FAKE_ENV = { "SystemDrive": "C:", "SystemRoot": r"C:\Windows", "windir": r"C:\Windows", "ProgramFiles": r"C:\Program Files", "ProgramFiles(x86)": r"C:\Program Files (x86)", "ProgramW6432": r"C:\Program Files", "ProgramData": r"C:\ProgramData", "USERPROFILE": r"C:\Users\dev", "APPDATA": r"C:\Users\dev\AppData\Roaming", "LOCALAPPDATA": r"C:\Users\dev\AppData\Local", "TEMP": r"C:\Users\dev\AppData\Local\Temp", "TMP": r"C:\Users\dev\AppData\Local\Temp", } def _load_validator(): spec = importlib.util.spec_from_file_location( "apply_cleanup_selection", HERE / "apply-cleanup-selection.py") mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod # Each case: (command, expect_ok, expect_requires_protection, scan_roots, note) CASES = [ # ---- Allowed bare deletes ------------------------------------------------- (r"Remove-Item -LiteralPath 'C:\Users\dev\AppData\Local\Temp\foo' -Recurse -Force", True, False, [], "user TEMP"), (r'Remove-Item -LiteralPath "C:\Users\dev\Downloads\old.iso" -Force', True, False, [], "Downloads under HOME"), (r"del C:\Windows\Temp\stale.tmp", True, False, [], "C:\\Windows\\Temp carve-out"), (r"Remove-Item -LiteralPath '%TEMP%\bar' -Recurse", True, False, [], "env-expanded TEMP"), (r"Remove-Item -LiteralPath 'C:\Users\dev\source\repos\proj\node_modules' -Recurse -Force", True, False, [], "project artifact under HOME"), (r"Remove-Item -LiteralPath 'D:\projects\app\target' -Recurse", True, False, [r"D:\projects"], "scan-root opt-in"), # ---- Denied bare deletes -------------------------------------------------- (r"Remove-Item -LiteralPath 'C:\Windows\System32\drivers\x' -Recurse", False, False, [], "System32"), (r"Remove-Item -LiteralPath 'C:\Windows\WinSxS' -Recurse", False, False, [], "WinSxS"), (r"Remove-Item -LiteralPath 'C:\Windows' -Recurse", False, False, [], "Windows root"), (r"Remove-Item -LiteralPath 'C:\Program Files\App' -Recurse", False, False, [], "Program Files"), (r"Remove-Item -LiteralPath 'C:\Program Files (x86)\App' -Recurse", False, False, [], "Program Files x86"), (r"Remove-Item -LiteralPath 'C:\ProgramData\Microsoft\X' -Recurse", False, False, [], "ProgramData\\Microsoft"), (r"Remove-Item -LiteralPath 'C:\ProgramData\Package Cache\X' -Recurse", False, False, [], "Package Cache"), (r"Remove-Item -LiteralPath 'C:\Users\dev\.ssh' -Recurse", False, False, [], "credential dir beats HOME allow"), (r"Remove-Item -LiteralPath 'C:\Users\dev\.aws\credentials' -Force", False, False, [], "AWS creds"), (r"Remove-Item -LiteralPath 'C:\Users\dev\AppData\Roaming\Microsoft\Protect\S-1-5' -Recurse", False, False, [], "DPAPI master keys"), (r"Remove-Item -LiteralPath 'C:\pagefile.sys' -Force", False, False, [], "pagefile"), (r"Remove-Item -LiteralPath 'C:\swapfile.sys' -Force", False, False, [], "swapfile"), (r"Remove-Item -LiteralPath 'C:\hiberfil.sys' -Force", False, False, [], "hiberfil"), (r"Remove-Item -LiteralPath 'C:\System Volume Information\x' -Recurse", False, False, [], "VSS/restore store"), (r"Remove-Item -LiteralPath 'C:\$Recycle.Bin\S-1-5\x' -Recurse", False, False, [], "recycle bin internals"), (r"Remove-Item -LiteralPath 'C:\' -Recurse", False, False, [], "bare drive root"), (r"Remove-Item -LiteralPath 'D:\randomstuff\x' -Recurse", False, False, [], "outside zones, no scan-root"), # ---- Canonicalization / spoofing attempts (must all be denied) ----------- (r"Remove-Item -LiteralPath 'c:\windows\system32\x' -Recurse", False, False, [], "case-insensitive deny"), (r"Remove-Item -LiteralPath 'C:/Windows/System32/x' -Recurse", False, False, [], "forward slashes normalized"), (r"Remove-Item -LiteralPath 'C:\Users\dev\..\..\Windows\System32\x' -Recurse", False, False, [], "dot-dot traversal collapses into Windows"), (r"Remove-Item -LiteralPath 'C:\WINDOWS.\System32\evil' -Recurse", False, False, [], "trailing-dot component stripped"), (r"Remove-Item -LiteralPath 'C:\PROGRA~1\App' -Recurse", False, False, [], "8.3 short name refused"), (r"Remove-Item -LiteralPath 'C:\Users\dev\file.txt:hidden' -Force", False, False, [], "alternate data stream refused"), (r"Remove-Item -LiteralPath '\\server\share\x' -Recurse", False, False, [], "UNC refused"), (r"Remove-Item -LiteralPath '\\?\C:\Windows\..\Windows\System32\x' -Recurse", False, False, [], "\\\\?\\ device path refused"), (r"Remove-Item -Path HKLM:\Software\Foo -Recurse", False, False, [], "registry provider refused"), (r"Remove-Item -Path Env:\Foo", False, False, [], "Env provider refused"), # ---- Command-injection / chaining (must all be denied) ------------------- (r"docker ps; Remove-Item C:\Windows -Recurse", False, False, [], "semicolon chaining"), (r"Remove-Item C:\Windows -Recurse | Out-Null", False, False, [], "pipe chaining"), (r"Remove-Item C:\Windows -Recurse & whoami", False, False, [], "ampersand chaining"), (r"[IO.Directory]::Delete('C:\Windows')", False, False, [], ".NET static call (::)"), (r"Remove-Item $(Get-Evil)", False, False, [], "subexpression"), # ---- Safe wrappers (ok, no protection) ----------------------------------- (r"dotnet nuget locals all --clear", True, False, [], "nuget cache"), (r"docker system prune -af", True, False, [], "docker prune"), (r"Clear-RecycleBin -Force", True, False, [], "recycle bin cmdlet"), (r"cleanmgr /sagerun:1", True, False, [], "disk cleanup profile"), (r"npm cache clean --force", True, False, [], "npm cache"), (r"winget uninstall --id Some.App", True, False, [], "winget uninstall"), (r"Dism.exe /Online /Cleanup-Image /AnalyzeComponentStore", True, False, [], "DISM analyze (read-only)"), (r"vssadmin list shadowstorage", True, False, [], "vssadmin list (read-only)"), # ---- Tool wrappers, destructive => require protection --------------------- (r"Dism.exe /Online /Cleanup-Image /StartComponentCleanup /ResetBase", True, True, [], "DISM /ResetBase irreversible"), (r"vssadmin delete shadows /for=C: /oldest", True, True, [], "delete shadow copies"), (r"wevtutil cl Application", True, True, [], "clear event log"), (r"pnputil /delete-driver oem42.inf /uninstall", True, True, [], "driver removal"), (r"powercfg /h off", True, True, [], "disable hibernation"), # ---- Neither delete nor wrapper => refused ------------------------------- (r"Stop-Service WSearch", False, False, [], "arbitrary cmdlet refused"), (r"Format-Volume -DriveLetter D", False, False, [], "format refused"), (r"", False, False, [], "empty command"), ] def test_load_selection_bom() -> int: """Regression for issue #1: PowerShell UTF8 selection JSON carries a BOM.""" import json import tempfile from pathlib import Path mod = _load_validator() payload = { "selected_items": [ { "id": "t1", "label": "temp sample", "path": r"C:\Users\dev\AppData\Local\Temp\sample", "command": ( r"Remove-Item -LiteralPath " r"'C:\Users\dev\AppData\Local\Temp\sample' " r"-Recurse -Force" ), "size_bytes": 1, } ], "protected_overrides": [], } body = json.dumps(payload, indent=2).encode("utf-8") bom_body = b"\xef\xbb\xbf" + body failed = 0 with tempfile.TemporaryDirectory() as td: plain = Path(td) / "plain.json" bom = Path(td) / "bom.json" plain.write_bytes(body) bom.write_bytes(bom_body) try: a = mod.load_selection_json(plain) b = mod.load_selection_json(bom) except Exception as exc: print(f"FAIL [BOM load raised]: {exc}") return 1 if a != payload or b != payload: print("FAIL [BOM load] decoded payload mismatch") failed += 1 else: print("OK [BOM load] utf-8 and utf-8-sig selection JSON both load") code = mod.main([str(HERE / "apply-cleanup-selection.py"), str(bom), "--dry-run"]) if code != 0: print(f"FAIL [BOM dry-run] exit={code}") failed += 1 else: print("OK [BOM dry-run] apply --dry-run accepts BOM selection") return failed def main() -> int: os.environ.update(FAKE_ENV) mod = _load_validator() passed = failed = 0 for command, exp_ok, exp_prot, scan_roots, note in CASES: ok, reason, prot = mod.validate_command(command, scan_roots) good = (ok == exp_ok) and (prot == exp_prot if exp_ok else True) if good: passed += 1 else: failed += 1 print(f"FAIL [{note}]") print(f" cmd = {command!r}") print(f" expected = ok={exp_ok} prot={exp_prot}") print(f" got = ok={ok} prot={prot} reason={reason!r}") bom_failed = test_load_selection_bom() failed += bom_failed if bom_failed == 0: passed += 2 print() print(f"=== {passed} passed | {failed} failed | {len(CASES)} total (+ BOM checks) ===") return 0 if failed == 0 else 1 if __name__ == "__main__": raise SystemExit(main()) -
win-health-check.config.ps1 1.4 KB · in bundle
-
win-health-check.ps1 11.8 KB · in bundle
-
-
references
-
alerting.md 9.4 KB
# Active alerting Design and operations of the `win-health-check` scheduled task — the active complement to passively watching Task Manager / Resource Monitor. Windows analog of the macOS `mac-health-check` LaunchAgent. ## Table of contents - [Design principles](#design-principles) - [Files (canonical paths)](#files-canonical-paths) - [Why these specific choices](#why-these-specific-choices) - [Install / restore on a new PC](#install--restore-on-a-new-pc) - [Verify it works](#verify-it-works) - [Configuration tuning](#configuration-tuning) - [Daily operations](#daily-operations) - [Troubleshooting](#troubleshooting) - [Removal](#removal) ## Design principles 1. **Three CRITICAL-only triggers**, no warnings or hints: - Disk free below configured % (default 10). - Commit pressure above % AND available RAM below % (defaults 90 / 5). **Commit % is primary** — `Available MBytes` includes standby/cache, so it alone is not a low-memory signal; commit-limit exhaustion is. - A new crash minidump in `C:\Windows\Minidump` (immediate, no hysteresis). 2. **Hysteresis**: 3 consecutive readings before the disk/memory alert (15 min on a 5-min cadence). Kills transient spikes. 3. **Cooldown**: 30 min between repeats of the same key. Prevents storms. 4. **Calibration window**: first 7 days log-only. Observe your noise floor before alerts engage. 5. **Suppress flag**: create `%LOCALAPPDATA%\win-health\silent` to mute during heavy work — no need to unregister the task. 6. **No auto-cleanup.** The alert notifies; the human decides. (Google SRE alert-fatigue consensus.) ## Files (canonical paths) ``` %LOCALAPPDATA%\win-health\win-health-check.ps1 # the script (installed copy) %LOCALAPPDATA%\win-health\win-health-check.config.ps1 # thresholds & switches %LOCALAPPDATA%\win-health\silent # touch to suppress %LOCALAPPDATA%\win-health\logs\health.log # script's own log %LOCALAPPDATA%\win-health\install_date # epoch of first run (calibration) %LOCALAPPDATA%\win-health\dumps_seen # dedup of crash dumps seen %LOCALAPPDATA%\win-health\counter.{disk,memory} # hysteresis counters %LOCALAPPDATA%\win-health\cooldown.<key> # cooldown timestamps Scheduled Task: \WinHealthCheck # per-user, interactive session ``` The skill's `assets/` directory holds the reference copies (`win-health-check.ps1`, `win-health-check.config.ps1`, `Install-WinHealthCheck.ps1`). ## Why these specific choices | Choice | Reason | |---|---| | **Task runs in the INTERACTIVE user session** (principal = logged-on user, `LogonType Interactive`, `RunLevel Limited`) | This is load-bearing. A task running as **SYSTEM** (or "run whether user is logged on or not") executes fine but its toasts **silently never render** — toasts are drawn by `explorer.exe` in the user's desktop session (Session 1+), not Session 0. This is the exact Windows analog of the macOS "osascript → Script Editor" failure. `Highest` run level is unnecessary for toasts and can hand the task a non-interactive token, so use `Limited`. | | **BurntToast for toasts** | BurntToast registers its own AppUserModelID, so the toast is attributed cleanly (not to "PowerShell") and click/activation works. Install once: `Install-Module BurntToast -Scope CurrentUser`. | | **Native WinRT as fallback** | Zero-dependency `ToastNotificationManager`, but **requires AUMID registration** or it doesn't render / is attributed to "PowerShell" — the same pathology as the macOS osascript fallback. Documented but secondary. | | **ntfy.sh as the headless escape hatch** | More important than on macOS: when there is **no interactive session** (locked machine, server SKU, SYSTEM task), toasts are impossible and ntfy is the only channel. `Invoke-RestMethod` works from any session. | | **`-Once` + `-RepetitionInterval 5min`** | The only reliable way to get indefinite sub-hourly repetition via the ScheduledTasks cmdlets. | | **`-StartWhenAvailable`** | Catch up once on wake after a missed trigger — the analog of the macOS plist's `StartCalendarInterval` coalescing (the macOS lesson was that `StartInterval` pauses during sleep). | | **`-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries`** | Default tasks are AC-only; without these a laptop on battery never checks — a silent-death mode the macOS LaunchAgent doesn't have. | | **Minidump file poll as primary crash signal; narrow `Get-WinEvent` only as confirmation** | A broad `Get-WinEvent` over a wide window is slow and throws "no events found" as a terminating error — the analog of the macOS `log show` being too slow. Polling `C:\Windows\Minidump` is cheap. | | **Commit % as primary memory signal** | `Available MBytes` counts reclaimable standby/cache; commit-limit exhaustion is the real OOM. | **Modern Standby (S0ix):** on many Windows 11 laptops, wake timers are unreliable and `powercfg /a` won't show S3 — this is normal, not a fault. The task pauses during sleep and `-StartWhenAvailable` runs it once on wake. Accepted, same trade-off as macOS. ## Install / restore on a new PC Run a **non-elevated** PowerShell as the user who should receive alerts (interactive-session principal). From the skill's `assets/`: ```powershell cd <skill>\assets # Installs BurntToast, copies script+config to %LOCALAPPDATA%\win-health, registers the task, # runs once to verify: .\Install-WinHealthCheck.ps1 ``` What it does: installs the BurntToast module (CurrentUser), copies the script and a default config, then registers `\WinHealthCheck` with the interactive-session principal, 5-minute repetition, `-StartWhenAvailable`, battery-friendly settings, and a 4-minute execution limit. To skip the module (use native WinRT or ntfy only): `.\Install-WinHealthCheck.ps1 -SkipBurntToast`. ## Verify it works ```powershell # Synthetic disk-critical alert (no real danger): forces DiskCriticalPct=99, hysteresis=1, calibration=0 .\Install-WinHealthCheck.ps1 -Test ``` You should see a toast and an `ALERT key=disk_critical … -> delivered` line in `%LOCALAPPDATA%\win-health\logs\health.log`. Confirm the task is registered: ```powershell Get-ScheduledTask -TaskName WinHealthCheck | Select-Object TaskName, State Get-ScheduledTaskInfo -TaskName WinHealthCheck | Select-Object LastRunTime, LastTaskResult, NextRunTime ``` ## Configuration tuning Edit `%LOCALAPPDATA%\win-health\win-health-check.config.ps1`: | Variable | Default | When to change | |---|---|---| | `$DiskCriticalPct` | 10 | lower if you routinely run >90% full and accept it; raise for earlier warning | | `$CommitCriticalPct` | 90 | the primary memory trigger; lower to 85 for earlier warning | | `$MemFreeCriticalPct` | 5 | confirmation gate; match your normal heavy-use floor + margin | | `$CooldownMinutes` | 30 | lower for more reminders; raise to silence repeats | | `$HysteresisReadings` | 3 | 1 = instant; 5–6 = very stable only | | `$CalibrationDays` | 7 | 0 to skip on a known-good restore | | `$Notifier` | auto | `burnttoast` / `winrt` / `none` to force | | `$NtfyUrl` | empty | `https://ntfy.sh/<long-unguessable-topic>` for phone/desktop push | | `$DumpDir` | `%SystemRoot%\Minidump` | override only for testing | ## Daily operations ```powershell # Is it running? Get-ScheduledTaskInfo -TaskName WinHealthCheck | Select-Object LastRunTime, LastTaskResult # Watch the log Get-Content (Join-Path $env:LOCALAPPDATA 'win-health\logs\health.log') -Wait -Tail 20 # Suppress during heavy work, then re-enable New-Item -ItemType File -Force -Path (Join-Path $env:LOCALAPPDATA 'win-health\silent') Remove-Item (Join-Path $env:LOCALAPPDATA 'win-health\silent') # Force a check now & powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $env:LOCALAPPDATA 'win-health\win-health-check.ps1') ``` ## Troubleshooting ### Task runs but no toast appears 1. **Most common:** the task is running as SYSTEM / "whether user is logged on or not". Re-run `Install-WinHealthCheck.ps1` so the principal is the interactive user (`LogonType Interactive`). Verify: `(Get-ScheduledTask WinHealthCheck).Principal`. 2. Confirm BurntToast: `Get-Module -ListAvailable BurntToast`. If absent, `Install-Module BurntToast -Scope CurrentUser`. 3. Check Settings → System → Notifications is on and Focus Assist / Do Not Disturb isn't suppressing it. 4. As a session-independent fallback, set `$NtfyUrl` and subscribe in the ntfy app. ### Toast attributed to "PowerShell" The native WinRT fallback fired without an AUMID. Install BurntToast (it registers an AUMID) and set `$Notifier = 'burnttoast'`. ### Task never runs on a laptop It was AC-only or paused during sleep. Re-run the installer (sets `-AllowStartIfOnBatteries`); accept that S0ix may delay the post-wake run (`-StartWhenAvailable` catches up once). ### Constant alerts during heavy work Past calibration and your normal workload exceeds thresholds. Suppress (`silent` file), raise thresholds, or increase `$HysteresisReadings`. ### "I want phone push too" Set `$NtfyUrl = 'https://ntfy.sh/<your-private-uuid-topic>'` (≥ 32 random chars — public topics are world-readable), subscribe in the ntfy mobile app, and test: ```powershell Invoke-RestMethod -Method Post -Uri 'https://ntfy.sh/<topic>' -Body 'test' -Headers @{ Title='Test'; Priority='high' } ``` ## Removal ```powershell .\Install-WinHealthCheck.ps1 -Uninstall # remove the task (keeps logs/state) .\Install-WinHealthCheck.ps1 -Uninstall -Purge # also delete %LOCALAPPDATA%\win-health # Optional: Uninstall-Module BurntToast ``` -
cleanup-tiers.md 15.3 KB
# Cleanup tiers Ten tiers, ordered by risk and reward. Always start at the lowest, only escalate if the goal isn't met. Each tier ends with a free-space checkpoint. **Prefer native Microsoft-supported tools and the tool's own cache command over hand-rolled `Remove-Item`** — see `native-tools.md`. Every destructive item the agent surfaces in the cleanup UI runs through `assets/apply-cleanup-selection.py`, which validates the command before executing; do not bypass it with ad-hoc deletions. ## Table of contents - [Tier 1 — Storage Sense + trivial wins](#tier-1--storage-sense--trivial-wins-low-risk) - [Tier 2 — Package manager caches](#tier-2--package-manager-caches) - [Tier 3 — Browser & Electron caches](#tier-3--browser--electron-caches) - [Tier 4 — Stale IDE / SDK versions](#tier-4--stale-ide--sdk-versions) - [Tier 5 — Downloads (interactive)](#tier-5--downloads-interactive) - [Tier 6 — Component Store (DISM)](#tier-6--component-store-dism) - [Tier 7 — Project artifacts (purge)](#tier-7--project-artifacts-purge) - [Tier 8 — Docker & WSL2 / Hyper-V VHDX](#tier-8--docker--wsl2--hyper-v-vhdx) - [Tier 9 — Elevated: Windows.old, DO, Event Logs, restore points](#tier-9--elevated-windowsold-delivery-optimization-event-logs-restore-points) - [Tier 10 — Discuss-first](#tier-10--discuss-first) - [Large-file visibility pass](#large-file-visibility-pass) - [Reset prevention recipes](#reset-prevention-recipes) - [After cleanup](#after-cleanup) **Baseline first:** ```powershell Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3' | Select-Object DeviceID, @{N='FreeGB';E={[math]::Round($_.FreeSpace/1GB,1)}}, @{N='FreePct';E={[math]::Round($_.FreeSpace/$_.Size*100,1)}} # For Tiers 3 (Electron) and 8 (Docker/WSL): quit the apps / `wsl --shutdown` first. ``` --- ## Tier 1 — Storage Sense + trivial wins (low risk) The biggest safe win on Windows is the built-in tooling. Start here. ```powershell # GUI: Settings → System → Storage → "Cleanup recommendations" (temp files, large/unused, cloud-synced, unused apps). # Enable Storage Sense to keep it tidy automatically (per-user): Start-Process ms-settings:storagesense # Recycle Bin (all drives) Clear-RecycleBin -Force -ErrorAction SilentlyContinue # User + Windows temp older than N days (Remove-Item is PERMANENT — no Recycle Bin) $cut = (Get-Date).AddDays(-14) Get-ChildItem $env:TEMP -Force -EA SilentlyContinue | Where-Object LastWriteTime -lt $cut | Remove-Item -Recurse -Force -EA SilentlyContinue Get-ChildItem "$env:SystemRoot\Temp" -Force -EA SilentlyContinue | Where-Object LastWriteTime -lt $cut | Remove-Item -Recurse -Force -EA SilentlyContinue # needs admin # Legacy Disk Cleanup, scripted (define a profile once, run it anywhere): cleanmgr /sageset:1 # tick categories in the dialog once cleanmgr /sagerun:1 # runs that profile on all drives ``` ⚠️ Leave **Prefetch** (`C:\Windows\Prefetch`) alone — deleting it slows boot for ~tens of MB. Don't delete `Windows.edb` (search index) live; rebuild via Settings or stopping `WSearch` instead. --- ## Tier 2 — Package manager caches All regenerate on next build. Use the tool's own command where it exists — they keep index integrity better than `Remove-Item`. ```powershell npm cache clean --force pnpm store prune dotnet nuget locals all --clear pip cache purge if (Get-Command uv -EA SilentlyContinue) { uv cache clean } if (Get-Command cargo -EA SilentlyContinue) { cargo cache --autoclean } # needs cargo-cache; else: Remove-Item "$env:USERPROFILE\.cargo\registry\cache" -Recurse -Force if (Get-Command go -EA SilentlyContinue) { go clean -cache; go clean -modcache } # Gradle wrapper dists / downloaded JDKs (regenerate on next build): Remove-Item "$env:USERPROFILE\.gradle\wrapper\dists" -Recurse -Force -EA SilentlyContinue Remove-Item "$env:USERPROFILE\.gradle\caches\jars-*" -Recurse -Force -EA SilentlyContinue # Playwright/Puppeteer browser binaries — list, then remove old versions only: Get-ChildItem "$env:USERPROFILE\AppData\Local\ms-playwright" -EA SilentlyContinue ``` --- ## Tier 3 — Browser & Electron caches ⚠️ **Quit the apps first.** Running apps regenerate cache mid-write and can glitch. ```powershell # Cache-family subfolders only — NEVER IndexedDB / Local Storage / Cookies / "User Data\Default" wholesale. $apps = @( "$env:APPDATA\Slack", "$env:APPDATA\Notion", "$env:APPDATA\Microsoft\Teams", "$env:LOCALAPPDATA\Microsoft\Edge\User Data\Default", "$env:LOCALAPPDATA\Google\Chrome\User Data\Default", "$env:APPDATA\Code", "$env:APPDATA\Cursor" ) $cacheSubs = 'Cache','Code Cache','GPUCache','DawnCache','DawnGraphiteCache','GrShaderCache','ShaderCache','Service Worker\CacheStorage' foreach ($a in $apps) { foreach ($s in $cacheSubs) { Remove-Item (Join-Path $a $s) -Recurse -Force -EA SilentlyContinue } } ``` Browser cache is also clearable from each browser's own Settings → Privacy → Clear browsing data (pick **Cached images and files** only; leave cookies/passwords). In Edge with sync on, clearing browsing data can affect synced devices. ⚠️ Do NOT delete `IndexedDB`, `Local Storage`, `Cookies`, Telegram `tdata\`, or anything carrying app state — see `never-touch.md`. --- ## Tier 4 — Stale IDE / SDK versions ```powershell # JetBrains: list data dirs, remove only confirmed-stale major.minor versions Get-ChildItem "$env:APPDATA\JetBrains" -Directory -EA SilentlyContinue | Select-Object Name # e.g. keep Rider2025.1, remove Rider2024.3: # Remove-Item "$env:APPDATA\JetBrains\Rider2024.3" -Recurse -Force # Caches/logs/indexes for current versions (re-index on next launch — slow but safe): Remove-Item "$env:LOCALAPPDATA\JetBrains\*\caches" -Recurse -Force -EA SilentlyContinue Remove-Item "$env:LOCALAPPDATA\JetBrains\*\log" -Recurse -Force -EA SilentlyContinue # .NET old SDKs — list first; remove only if a newer same-major exists: dotnet --list-sdks # Visual Studio leftover component cache: Remove-Item "$env:ProgramData\Microsoft\VisualStudio\Packages\_Instances\*\..\..\..\*.cache" -EA SilentlyContinue ``` --- ## Tier 5 — Downloads (interactive) Largest variable category. Show the breakdown first; always confirm before delete. ```powershell Get-ChildItem "$env:USERPROFILE\Downloads" -File -EA SilentlyContinue | Sort-Object Length -Desc | Select-Object -First 30 Name, @{N='MB';E={[math]::Round($_.Length/1MB,1)}}, LastWriteTime ``` ⚠️ **OneDrive Known Folder Move:** Downloads is usually local, but **Desktop / Documents / Pictures are often redirected into OneDrive**. Deleting a redirected file removes the cloud copy and propagates to every synced device. Check before touching those folders (the audit script reports KFM status). Propose for deletion: installers (`.iso/.msi/.exe/.zip`) older than 30 days that were already used; extracted folders sitting next to their archive; old recordings. --- ## Tier 6 — Component Store (DISM) WinSxS grows with updates. **Never delete WinSxS by hand** — Microsoft says the system can stop booting/updating. Analyze, then clean only if recommended. ```powershell Dism.exe /Online /Cleanup-Image /AnalyzeComponentStore # look for "Component Store Cleanup Recommended : Yes" Dism.exe /Online /Cleanup-Image /StartComponentCleanup # only if reclaimable / recommended ``` `/ResetBase` (removes the ability to uninstall existing updates) is **Tier 10**, not here. --- ## Tier 7 — Project artifacts (purge) Highest-reward tier on a dev machine. Regenerable build/dependency dirs, age-gated. There is **no mature Mole-for-Windows**, so the apply-script validator is the only safety floor here — keep `mtime ≥ 7 days` and `-LiteralPath`, and never recurse through a reparse point (junctions in `node_modules`/pnpm store/WSL escape recursive deletes). See `native-tools.md` for the marker→target map and the safe-purge harness. ```powershell # Inventory candidate roots (HOME by default; pass external drives as scan roots to the apply script): $roots = "$env:USERPROFILE\source","$env:USERPROFILE\repos","$env:USERPROFILE\dev","$env:USERPROFILE\Projects" $cut = (Get-Date).AddDays(-7) foreach ($r in $roots) { if (-not (Test-Path $r)) { continue } Get-ChildItem $r -Recurse -Depth 4 -Directory -Force -EA SilentlyContinue | Where-Object { $_.Name -in 'node_modules','.next','.nuxt','dist','build','target','__pycache__','.venv','.gradle' -and $_.LastWriteTime -lt $cut -and -not ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) } | Select-Object FullName, LastWriteTime } # Surface the confirmed list in the cleanup UI; apply via apply-cleanup-selection.py (per-item -LiteralPath Remove-Item). ``` ⚠️ `bin`/`obj` only for confirmed .NET projects (sibling `*.csproj`); `vendor` only for PHP Composer (Go/Rails `vendor` is hand-curated). Check `git status` for uncommitted state before deleting build dirs. --- ## Tier 8 — Docker & WSL2 / Hyper-V VHDX ```powershell docker system df # see reclaimable docker image prune -f docker builder prune -f docker volume ls -f dangling=true # review BEFORE removing — data volumes live here ``` ⚠️ Never `docker system prune -af --volumes` without listing volumes — it deletes data volumes (postgres/mongo data) unconfirmed. **WSL2 / Docker-Desktop VHDX — compact, never delete.** `ext4.vhdx` is the entire Linux filesystem; deleting it destroys all your WSL data. It grows and doesn't auto-shrink. Reclaim by compacting: ```powershell wsl --shutdown # Find the disk, then compact it (run from an elevated prompt): $vhdx = "$env:LOCALAPPDATA\Packages\<DistroPackage>\LocalState\ext4.vhdx" Optimize-VHD -Path $vhdx -Mode Full # Hyper-V module; OR use diskpart 'compact vdisk' on Home SKUs ``` --- ## Tier 9 — Elevated: Windows.old, Delivery Optimization, Event Logs, restore points Each needs admin and an explicit OK. Use the **command**, not folder deletion. ```powershell # Windows.old — ONLY after confirming you won't roll back. Check the rollback window first: Dism.exe /Online /Get-OSUninstallWindow # Then remove via Settings → System → Storage → Temporary files → "Previous Windows installation(s)", or cleanmgr. # Delivery Optimization cache (auto-capped anyway; point fix, not routine): Delete-DeliveryOptimizationCache -Force # Event Logs — EXPORT before clearing (forensics). Pattern: export then clear: wevtutil epl Application "$env:USERPROFILE\Desktop\Application-backup.evtx" /ow:true wevtutil cl Application /bu:"$env:USERPROFILE\Desktop\Application-before-clear.evtx" # System Restore points — only if you have another backup/rollback: vssadmin list shadowstorage # vssadmin delete shadows /for=C: /oldest # destroys restore points — discuss first ``` --- ## Tier 10 — Discuss-first Each item has real, often irreversible side effects. Explicit user OK required (the apply-script flags these as requiring a protected-override even though the tool is "supported"). ```powershell # DISM /ResetBase — removes ability to UNINSTALL existing updates. Stable baselines only. Dism.exe /Online /Cleanup-Image /StartComponentCleanup /ResetBase # Driver Store — remove stale third-party packages AFTER inventory/export: pnputil /enum-drivers # pnputil /export-driver oem42.inf C:\DriverBackup # back up first # pnputil /delete-driver oem42.inf /uninstall # Shadow copies / restore points (loses rollback): # vssadmin delete shadows /for=C: /all # Hibernation off (frees hiberfil.sys; breaks hybrid sleep / fast startup; data-loss risk on power loss): # powercfg /h off # or shrink: powercfg /h /type reduced # Pagefile — almost never "junk". Needed for modified pages + crash dumps. Leave system-managed. Get-CimInstance Win32_PageFileUsage | Select-Object Name, AllocatedBaseSize, CurrentUsage, PeakUsage ``` ⚠️ **Registry is never a cleanup target.** Microsoft does not support registry cleaners; "registry bloat" is a myth as a space/perf source. Only export-and-edit a specific known key when fixing a concrete problem. --- ## Large-file visibility pass Run this after the tier scans and before building the cleanup JSON. Its purpose is visibility, not automatic deletion: it catches the large one-off files the user may no longer need, while keeping them unchecked/protected in the single final UI unless they are clearly safe cache. ```powershell $roots = @( $env:USERPROFILE, $env:LOCALAPPDATA, $env:APPDATA, "$env:USERPROFILE\RiderProjects", "$env:USERPROFILE\PycharmProjects", "$env:USERPROFILE\source", "$env:USERPROFILE\repos", "$env:USERPROFILE\dev", "$env:USERPROFILE\Projects", "$env:USERPROFILE\GitHub", "$env:USERPROFILE\Code", "$env:USERPROFILE\work" ) | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -Unique $denyRegex = '\\\.git\\objects\\|\\Windows\\|\\Program Files( \(x86\))?\\|\\ProgramData\\Package Cache\\' foreach ($root in $roots) { Get-ChildItem -LiteralPath $root -Recurse -File -Force -EA SilentlyContinue | Where-Object { $_.Length -gt 500MB -and $_.FullName -notmatch $denyRegex -and -not ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -and -not ($_.Attributes -band [IO.FileAttributes]::Offline) } | Sort-Object Length -Desc | Select-Object -First 80 FullName, @{N='GB';E={[math]::Round($_.Length/1GB,2)}}, LastWriteTime, Extension } ``` When converting these findings to JSON: - Mark user data, synced media, model weights (`.gguf`, `.safetensors`, `.onnx`), archives, app/project assets, app diagnostic dumps, and profiler captures as `protected: true`, `default_selected: false`, with a concrete warning. - Include those protected findings in the same final cleanup JSON as the safe cache/temp/build-artifact candidates. Do not open a second UI round just for large files. - Show VHDX files as inventory, but do not offer bare deletion. Use compact/prune workflows only; a WSL/Docker VHDX may be an entire filesystem. - Do not offer `.git\objects` files as direct deletes. If a repository is too large, propose Git-native cleanup (`git gc`, removing stale branches, deleting the whole repo if the user chooses), not object-file deletion. - Do not include cloud placeholders or Files On-Demand stubs. Deleting them deletes the cloud item. --- ## Reset prevention recipes ```powershell # Storage Sense policy: run monthly, clear Recycle Bin > 30d (Settings → Storage → Storage Sense), or via Intune/GPO in a fleet. # pnpm global store with hardlinks (dedupes across projects): pnpm config set store-dir "$env:LOCALAPPDATA\pnpm-store" # Docker tidy alias (PowerShell profile): 'function docker-tidy { docker container prune -f; docker image prune -f; docker builder prune -f }' | Add-Content $PROFILE # Cap WSL memory so it stops ballooning (%USERPROFILE%\.wslconfig): "[wsl2]`nmemory=8GB`nswap=2GB" | Set-Content "$env:USERPROFILE\.wslconfig" ``` --- ## After cleanup ```powershell # 1. Report the delta: Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='C:'" | Select-Object @{N='FreeGB';E={[math]::Round($_.FreeSpace/1GB,1)}} # 2. Integrity post-check (Microsoft-recommended order): Dism /Online /Cleanup-Image /CheckHealth sfc /scannow # 3. Re-check Windows Update opens, drivers intact, restore points still per plan. # 4. Recommend the alerter (alerting.md) to prevent recurrence. ``` If freed space looks smaller than deleted, allow a minute — Storage Sense/`cleanmgr` defer some deletion, and `Remove-Item` to Recycle-Bin-bypassing paths is immediate while NTFS metadata settles. -
native-tools.md 9.4 KB
# Native tools & the safety floor macOS has Mole (`mo`) as a community-vetted safety floor. **Windows has no mature equivalent** — so the safety floor here is two things working together: (1) **native Microsoft-supported tooling** for everything the OS maintains itself, and (2) **the apply-script validator** (`assets/apply-cleanup-selection.py`) for bare deletes. On Windows the validator is the *only* line behind project-artifact purge, not a second line behind a vetted tool. Treat it accordingly. ## Table of contents - [Native tooling = first choice](#native-tooling--first-choice) - [Marker → target map for project-artifact purge](#marker--target-map-for-project-artifact-purge) - [Age thresholds](#age-thresholds) - [Path-validation rules (what the validator enforces)](#path-validation-rules-what-the-validator-enforces) - [Operational logging](#operational-logging) - [Safe-purge harness (when scripting by hand)](#safe-purge-harness-when-scripting-by-hand) - [Third-party tools — optional, with caveats](#third-party-tools--optional-with-caveats) ## Native tooling = first choice | Tool | For | Notes | |---|---|---| | **Storage Sense** | ongoing temp/Recycle Bin/cloud dehydration | per-user; configurable via Settings, or Intune/CSP/GPO in a fleet | | **Cleanup recommendations** | one-shot GUI audit | Settings → System → Storage; temp, large/unused, cloud-synced, unused apps | | **cleanmgr** | scripted/legacy cleanup | `/sageset:N` defines a profile, `/sagerun:N` runs it on all drives | | **DISM** | Component Store (WinSxS) | `/AnalyzeComponentStore` then `/StartComponentCleanup`; `/ResetBase` is discuss-first | | **pnputil** | Driver Store | `/enum-drivers`, `/export-driver` (back up), `/delete-driver` | | **vssadmin** | shadow copies / restore | `list`/`delete`/`resize shadowstorage` | | **wevtutil** | Event Logs | `epl`/`al` (export) before `cl` (clear) | | **powercfg** | hiberfil / power state | `/h off`, `/h /type reduced`, `/a` | | **Clear-RecycleBin** | Recycle Bin | cmdlet, safe by construction | | **winget** | install/update/uninstall apps | `winget uninstall` is the clean app-removal path | These are the **wrapper** commands the validator trusts. The destructive subset (`vssadmin delete`, `Dism …/ResetBase`, `wevtutil cl`, `pnputil /delete-driver`, `powercfg /h off`) still requires an explicit protected-override even though the tool self-polices — because the effect is irreversible. ## Marker → target map for project-artifact purge Find dev projects by marker files; remove regenerable build/dependency dirs only. All targets must be **`LastWriteTime` ≥ 7 days old**. | Marker | Tooling | Targets | |---|---|---| | `package.json`, `pnpm-lock.yaml`, `yarn.lock` | npm/pnpm/yarn | `node_modules`, `.next`, `.nuxt`, `.output`, `dist`, `build`, `.turbo`, `.parcel-cache` | | `*.csproj`, `*.fsproj`, `*.vbproj`, `*.sln` | .NET | `bin`, `obj` (only with a sibling project file) | | `Cargo.toml` | Rust | `target` | | `pom.xml` | Maven | `target` | | `build.gradle(.kts)` | Gradle | `build`, `.gradle` | | `pyproject.toml`, `requirements.txt` | Python | `__pycache__`, `.venv`, `venv`, `.pytest_cache`, `.mypy_cache`, `.ruff_cache`, `.tox` | | `composer.json` | PHP Composer | `vendor` (Go/Rails `vendor` is protected — hand-curated) | | `pubspec.yaml` | Flutter/Dart | `.dart_tool`, `build` | | `go.mod` | Go | `go clean -cache`/`-modcache` (don't delete `vendor`) | | `angular.json` / `svelte.config.*` / `astro.config.*` | frameworks | `.angular` / `.svelte-kit` / `.astro` | | any project | universal | `coverage`, `.cache` | Default scan roots (under HOME): `%USERPROFILE%\source`, `\repos`, `\dev`, `\Projects`, `\GitHub`, `\Code`, `\work`. External drives must be passed explicitly (`--scan-root D:\projects`) — the validator denies bare deletes outside HOME/TEMP/scan-roots by default. ### Guards (mirror Mole's) - **`bin`/`obj`**: only if a sibling `*.csproj`/`*.fsproj`/`*.vbproj` exists (avoids deleting Go binaries / generated CLI dirs). - **`vendor`**: only with `composer.json`. - **Project root**: a marker file present and `.git` nearby. - **Reparse points**: never recurse into a junction/symlink (`FILE_ATTRIBUTE_REPARSE_POINT`) — recursive delete can escape into the target. ## Age thresholds ``` temp files / caches : LastWriteTime ≥ 7 days crash dumps : keep until triaged (forensics) — never age-delete blindly project artifacts : LastWriteTime ≥ 7 days orphan app data : ≥ 30 days ``` Always include the age filter in any `Get-ChildItem | Where-Object LastWriteTime -lt …` sweep. This single rule eliminates 90% of "I just built that yesterday" surprises. ## Path-validation rules (what the validator enforces) `assets/apply-cleanup-selection.py` is the safety core. It is a Windows **rewrite**, not a translation of the macOS validator, because NTFS/PowerShell give five separate ways to spell a protected path that a naive `startswith` misses. For every bare-delete command it: 1. Rejects chaining/redirection metacharacters outright: `; | & \` $( @( :: > <` and newlines (PowerShell's injection surface is far wider than bash). 2. Expands `%VAR%`/`$VAR`, then per path token rejects: UNC/device paths (`\\?\`, `\\.\`, `\\server`), 8.3 short names (`PROGRA~1`), non-filesystem providers (`HKLM:`, `Env:`, `Cert:`), and Alternate Data Streams (`file.txt:hidden`). 3. Canonicalizes like Win32 `GetFullPath` (unify separators, collapse `..`, strip trailing dots/spaces), then requires a drive-absolute path and refuses a bare drive root. 4. Classifies the path against an allow-list (HOME, TEMP, `%SystemRoot%\Temp`, scan roots) and a deny-list (the `never-touch.md` hard-protected prefixes) using **longest-prefix-wins**, so carve-outs resolve correctly (`C:\Windows` denied but `C:\Windows\Temp` allowed; `C:\Users\me` allowed but `C:\Users\me\.ssh` denied). 5. Refuses any registry-provider delete entirely. Wrappers are trusted to self-police; the irreversible-destructive ones additionally require a protected-override. The adversarial test suite (`assets/test_validate_command.py`) pins all of this and runs on any OS — run it after any edit to the validator. ## Operational logging Every applied operation is appended (TSV, append-only) to `%LOCALAPPDATA%\win-health\operations.log`: ``` TIMESTAMP \t apply-selection \t ACTION \t PATH \t SIZE \t STATUS 2026-06-07 09:12:43 apply-selection REMOVED C:\Users\dev\proj\node_modules 1.1 GB OK ``` It's the audit trail when "what did we delete?" comes up later. Keep the same format for any hand-rolled sweep. ## Safe-purge harness (when scripting by hand) If you must sweep without the apply script, port these guards: ```powershell function Invoke-SafeRemove { param([Parameter(Mandatory)][string]$Target) $full = [System.IO.Path]::GetFullPath([Environment]::ExpandEnvironmentVariables($Target)) if ($full -notmatch '^[A-Za-z]:\\') { return } # drive-absolute only $item = Get-Item -LiteralPath $full -Force -EA SilentlyContinue if (-not $item) { return } if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { return } # never follow junctions $low = $full.ToLowerInvariant() foreach ($deny in @("$env:SystemRoot","$env:ProgramFiles","${env:ProgramFiles(x86)}", "$env:USERPROFILE\.ssh","$env:APPDATA\Microsoft\Protect")) { $d = ([System.IO.Path]::GetFullPath($deny)).ToLowerInvariant() if ($low -eq $d -or $low.StartsWith($d + '\')) { return } } Remove-Item -LiteralPath $full -Recurse -Force -EA SilentlyContinue } # Apply with an age filter: Get-ChildItem $root -Recurse -Depth 4 -Directory -Force -EA SilentlyContinue | Where-Object { $_.Name -eq 'node_modules' -and $_.LastWriteTime -lt (Get-Date).AddDays(-7) -and -not ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) } | ForEach-Object { Invoke-SafeRemove $_.FullName } ``` This is the minimal harness; the full apply-script validator is stricter (8.3/ADS/UNC/provider/longest-prefix). Prefer it. ## Third-party tools — optional, with caveats Use these as an *overlay* for user/app caches, never as a replacement for native tooling. Several have a real CLI: | Tool | CLI | Use | Caveat | |---|---|---|---| | **BleachBit** | `bleachbit_console.exe --preview --preset` then `--clean` | transparent cache/temp/browser cleaning | always `--preview` first; permanent | | **BCUninstaller** | console interface | bulk app removal with leftovers | review the leftover list | | **WizTree** | `wiztree64.exe C: /export=...` | fast disk audit (read MFT) | audit only, not a cleaner | | **Autorunsc** | `autorunsc -accepteula -a * -c` | startup inventory | disabling entries is the change, not deletion | | **winget** | `winget uninstall` | clean app removal | preferred over manual folder delete | **Avoid as a primary choice on a work machine:** - **Mole for Windows** — the author's own Windows branch is flagged "currently not mature" and "do not use" on machines with important data. Interesting for a test box; not the safe default here. Prefer `cleanmgr` / Storage Sense / DISM + the apply script. - **CCleaner Registry Cleaner** — registry cleaning is unsupported by Microsoft and can destabilize the system. If CCleaner is used at all, restrict to **Custom Clean** and disable the registry feature. - Any **"RAM optimizer" / "memory booster"** — purging working sets/standby lists degrades performance. For memory problems, diagnose causes (RAMMap/Autoruns/Task Manager), don't "free RAM". -
never-touch.md 10.9 KB
# Never-touch list Categories that **must not be deleted** even if the user asks, even elevated, even when desperate for space. Pushback expected: explain the consequence, suggest an alternative. The hard-protected prefixes below are kept in sync with the deny-list in `assets/apply-cleanup-selection.py` — if you edit one, edit both. ## Table of contents - [Golden rule: clean with commands, not by deleting folders](#golden-rule-clean-with-commands-not-by-deleting-folders) - [Hard-protected paths (system roots)](#hard-protected-paths-system-roots) - [Paging / hibernation / crash files](#paging--hibernation--crash-files) - [Boot, recovery & volume metadata](#boot-recovery--volume-metadata) - [Credential & key stores](#credential--key-stores) - [Cloud placeholders (Files On-Demand) & OneDrive KFM](#cloud-placeholders-files-on-demand--onedrive-kfm) - [Per-app folders that LOOK like cache but hold state](#per-app-folders-that-look-like-cache-but-hold-state) - [The registry is not a cleanup target](#the-registry-is-not-a-cleanup-target) - [Windows-only dangers with no macOS analog](#windows-only-dangers-with-no-macos-analog) - [How to handle pushback](#how-to-handle-pushback) --- ## Golden rule: clean with commands, not by deleting folders Windows maintains several areas itself. Hand-deleting them corrupts the OS; the **supported command** does it safely. Memorize this mapping — most "free space" footguns are someone reaching for `Remove-Item` where a command was required: | Area | NEVER `Remove-Item` | Use instead | |---|---|---| | Component Store (`WinSxS`) | manual delete = unbootable / un-updatable | `Dism /Online /Cleanup-Image /StartComponentCleanup` | | Driver Store (`System32\DriverStore`) | breaks devices / driver rollback | `pnputil /delete-driver` (after export) | | Shadow copies / restore points (`System Volume Information`) | loses all rollback | `vssadmin` / System Protection | | Event Logs | loses forensics | `wevtutil epl` then `cl /bu:` | | Windows Update store (`SoftwareDistribution`) | breaks WU | stop `wuauserv`+`bits`, rename, restart | | MSI cache (`Installer`, `Package Cache`) | breaks uninstall/repair of every MSI app | leave it; uninstall apps via winget/Apps | | pagefile / hiberfil | crash on delete | `powercfg`, System settings | | Registry | instability up to reinstall | export + targeted edit only | --- ## Hard-protected paths (system roots) Never delete inside these prefixes — one wrong path can brick the OS. (`%SystemRoot%` is normally `C:\Windows`; covers all of System32, SysWOW64, WinSxS, DriverStore, config, SoftwareDistribution, Installer, Minidump, Prefetch, servicing, assembly, Microsoft.NET, catroot/catroot2.) ``` %SystemRoot% (C:\Windows and everything under it) %ProgramFiles% (C:\Program Files) %ProgramFiles(x86)% (C:\Program Files (x86)) %ProgramW6432% C:\ProgramData\Microsoft (crypto, Defender, provisioning) C:\ProgramData\Package Cache (MSI/Burn bootstrapper — repair/uninstall) ``` The one carve-out inside `C:\Windows` that IS cleanable is `C:\Windows\Temp` (the validator allows it via longest-prefix-wins). Everything else under `%SystemRoot%` is off-limits to bare deletes. ## Paging / hibernation / crash files Two separate swap files on Windows (macOS has one concept) — protect both: ``` C:\pagefile.sys # classic page file — modified pages + crash dumps C:\swapfile.sys # UWP/Store app swap — separate from pagefile C:\hiberfil.sys # hibernation image — manage via powercfg /h C:\DumpStack.log.tmp %SystemRoot%\MEMORY.DMP, %SystemRoot%\Minidump # forensics until the crash is triaged ``` `pagefile.sys` is **not junk**: it backs infrequently-used modified pages and is required for crash dumps. Size tracks peak commit + dump policy, not a "× RAM" formula. Leave it system-managed. ## Boot, recovery & volume metadata A sudo-equivalent recursive delete can reach these even though they're hidden. **On a BitLocker volume, damaging boot/EFI/Recovery triggers a recovery-key prompt the user may not have** — doubly important. ``` EFI System Partition, \Boot, \EFI, bootmgr, \BCD C:\$Recycle.Bin # use Clear-RecycleBin, never Remove-Item internals C:\System Volume Information # VSS shadow data + restore points + search index C:\Recovery # WinRE.wim — recovery environment C:\$WinREAgent # in-progress update rollback C:\Config.Msi # transactional MSI rollback (lethal mid-install) C:\PerfLogs ``` Also: never delete the **BitLocker recovery key** if escrowed locally. ## Credential & key stores Tiny but irreplaceable. Cleaner tools that scan `$HOME` for "old hidden files" flag these — never include them, even in a "clean old dotfiles" pass. The validator denies them even though they sit under the allowed HOME root (deny beats allow via longest-prefix). | Path | What it stores | Recovery if deleted | |---|---|---| | `%USERPROFILE%\.ssh` | private SSH keys | none — regenerate + redistribute pubkeys | | `%USERPROFILE%\.gnupg` | GPG secret keyring | none | | `%USERPROFILE%\.aws`, `.azure`, `.kube` | cloud access keys / kube contexts | re-issue / re-fetch | | `%USERPROFILE%\.docker\config.json` | registry auth tokens | `docker login` again | | `%USERPROFILE%\.netrc`, `.git-credentials` | plaintext credentials | from password manager | | `%APPDATA%\Microsoft\Protect` | **DPAPI master keys** | deletion = all DPAPI-encrypted blobs unrecoverable | | `%APPDATA%\Microsoft\Crypto`, `\SystemCertificates` | RSA/DSS keys, certs | none | | `%APPDATA%\Microsoft\Credentials`, `%LOCALAPPDATA%\Microsoft\Credentials` | Credential Manager vault | none | | `%APPDATA%\gh\hosts.yml` | GitHub CLI tokens | `gh auth login` | Heuristic: any dotfile/dotfolder under `%USERPROFILE%` outside an explicit cache (e.g. `.cache`, `.npm\_cacache`, `.gradle\caches`) should be assumed credential- or config-bearing. ## Cloud placeholders (Files On-Demand) & OneDrive KFM Detect by **file attribute**, not by path list — this generically covers OneDrive, Dropbox, Google Drive: - Refuse to delete any item with `FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS`, `RECALL_ON_OPEN`, or `OFFLINE` set. These are dehydrated cloud placeholders; deleting the stub deletes the cloud original and propagates to every device. - **OneDrive Known Folder Move (KFM):** `Desktop`, `Documents`, `Pictures` are frequently redirected into `%OneDrive%`. So "clean the Desktop" or "review Downloads" has different stakes there. Resolve those folders and check for a reparse point / `*OneDrive*` target before treating them as local (the audit script reports this). `Downloads` is usually NOT redirected; `Desktop`/`Documents` often ARE. ## Per-app folders that LOOK like cache but hold state Windows has no bundle-ID namespace, so protection is **path-based** under `%APPDATA%` / `%LOCALAPPDATA%`. Only the cache-family subfolders (`Cache`, `Code Cache`, `GPUCache`, `Service Worker\CacheStorage`, `DawnCache`) are safe; everything else is state. | App | Path | What it actually is | |---|---|---| | Telegram | `%APPDATA%\Telegram Desktop\tdata\` | full chat history + session keys — delete = logout + lost history | | Slack | `%APPDATA%\Slack\storage\`, `IndexedDB\` | workspace prefs, draft messages | | Notion | `%APPDATA%\Notion\...\IndexedDB\` | offline page data | | Teams | `%APPDATA%\Microsoft\Teams\` (and the new Store app data) | account/auth + cached messages | | Browsers | `...\User Data\Default\` (Edge/Chrome/Brave) | history, cookies, **extension state incl. password managers / MetaMask seed** | | VS Code / Cursor | `%APPDATA%\Code\User\`, `globalStorage\` | settings, snippets, extension state | | JetBrains | `%APPDATA%\JetBrains\<IDE><ver>\` | settings, project list, keymaps, licenses | | WSL distros | `%LOCALAPPDATA%\Packages\<distro>\LocalState\ext4.vhdx` | the **entire Linux filesystem** — compact, never delete | | Docker Desktop | `%LOCALAPPDATA%\Docker\wsl\...\*.vhdx` | images + volumes — compact, never delete | | Password managers | `%LOCALAPPDATA%\1Password`, `%APPDATA%\Bitwarden` etc. | vault/session state | | Crypto wallets | `%APPDATA%\Exodus`, `Ledger Live`, browser-extension state | encrypted wallet / seed-derived data | When unsure for an AppData folder: only `Cache`/`Code Cache`/`GPUCache`/`Service Worker` subdirs are safe. Everything else, ask. ## The registry is not a cleanup target Microsoft does not support registry cleaners; "registry bloat" is not a meaningful space or performance source. The apply-script **refuses any delete against a registry provider** (`HKLM:`, `HKCU:`, …). For a concrete fix, export the specific key (`reg export` / Registry Editor) and edit it by hand — never bulk-"optimize". ## Windows-only dangers with no macOS analog These are easy to forget because macOS has no equivalent: 1. **Reparse points / junctions / symlinks.** `Remove-Item -Recurse` can follow a junction and delete the *target's* contents — the classic data-loss bug. Dev trees are full of junctions (npm/pnpm store, Docker, WSL). Always use `-LiteralPath`; never recurse through `FILE_ATTRIBUTE_REPARSE_POINT` (the audit script lists reparse points under scan roots). 2. **`Remove-Item` is permanent — no Recycle Bin.** Unlike dragging to the bin, `Remove-Item` deletes immediately. Users expect "delete = recoverable"; it isn't here. Prefer moving to a dated quarantine folder over `Remove-Item` when the user is unsure. 3. **Controlled Folder Access (ransomware protection).** If on, deletes in Documents/Pictures/Desktop fail with Access Denied **from Defender, not from permissions**. Distinguish a CFA block from a real error so the tool isn't blamed (`Get-MpPreference | Select EnableControlledFolderAccess`). 4. **System Restore / VSS.** `vssadmin delete shadows` frees space but destroys the user's rollback net (the analog of Time Machine local snapshots). Treat like the macOS `tmutil` rule: only via the supported tool, only after warning. 5. **MSI `Installer` cache + `Package Cache`.** Every "free space" tutorial wrongly recommends cleaning `C:\Windows\Installer` — it breaks uninstall/repair/patch for every MSI app. Hard no. 6. **Prefetch.** Cleaner-tool muscle memory reaches for it; deleting it slows boot for negligible space. Leave it. ## How to handle pushback User says "I know it's risky, just delete it": 1. State the consequence concretely ("you'll lose every System Restore point and can't roll back this driver update", not "system data"). 2. Offer an alternative: the supported command, an export/backup first, or move-to-quarantine instead of delete. 3. If they insist, document in chat: exact path, exact action, exact consequence. Make them say "yes, delete X which holds Y, I accept losing Z." 4. Even then prefer non-destructive: `Move-Item` to `%USERPROFILE%\.quarantine-YYYYMMDD\` over `Remove-Item`. Easy to recover if regretted. Risk is asymmetric: false caution costs disk space; one bad delete costs weeks of work, an un-decryptable DPAPI vault, or an unbootable machine. Bias toward caution. -
triage.md 8 KB
# Triage — first 5 minutes When the user reports trouble, or an alert fires, identify which signal class fired before doing anything destructive. The right response differs. Run the snapshot read-only; nothing here deletes anything. ## Table of contents - [Quick state snapshot](#quick-state-snapshot-always-run-first) - [Signal classification](#signal-classification) - [A. Disk-driven](#a-disk-driven-most-common) - [B. Memory / commit-driven](#b-memory--commit-driven) - [C. BSOD / crash / unexpected shutdown](#c-bsod--crash--unexpected-shutdown) - [D. New crash dump appeared](#d-new-crash-dump-appeared) - [E. "PC just feels slow"](#e-pc-just-feels-slow) - [Decision tree](#decision-tree) - [What to NOT do at triage](#what-to-not-do-at-triage) ## Quick state snapshot (always run first) Run in an **elevated** PowerShell (`Get-Counter` memory objects, `Get-WinEvent System`, and DISM need admin). Everything here is read-only. ```powershell Write-Host "=== Disk ===" Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3' | Select-Object DeviceID, @{N='SizeGB';E={[math]::Round($_.Size/1GB,1)}}, @{N='FreeGB';E={[math]::Round($_.FreeSpace/1GB,1)}}, @{N='FreePct';E={[math]::Round($_.FreeSpace/$_.Size*100,1)}} | Format-Table -Auto Write-Host "=== Memory / commit ===" $os = Get-CimInstance Win32_OperatingSystem "RAM free % = {0}" -f [math]::Round($os.FreePhysicalMemory/$os.TotalVisibleMemorySize*100,1) Get-Counter '\Memory\Available MBytes','\Memory\% Committed Bytes In Use','\Paging File(_Total)\% Usage' -EA SilentlyContinue | Select-Object -Expand CounterSamples | Select-Object Path, CookedValue | Format-Table -Auto Write-Host "=== Recent crashes (7d) ===" Get-WinEvent -FilterHashtable @{LogName='System'; Id=41,1001,6008; StartTime=(Get-Date).AddDays(-7)} -EA SilentlyContinue | Select-Object TimeCreated, Id, ProviderName | Sort-Object TimeCreated -Desc | Select-Object -First 10 | Format-Table -Auto Get-ChildItem "$env:SystemRoot\Minidump" -Filter *.dmp -EA SilentlyContinue | Sort-Object LastWriteTime -Desc | Select-Object -First 5 Name, LastWriteTime Write-Host "=== Top working-set processes ===" Get-Process | Sort-Object WS -Desc | Select-Object -First 15 Name, Id, @{N='WS_MB';E={[math]::Round($_.WS/1MB)}}, @{N='CPU';E={[math]::Round($_.CPU)}} | Format-Table -Auto Write-Host "=== Health-check log if installed ===" $hl = Join-Path $env:LOCALAPPDATA 'win-health\logs\health.log' if (Test-Path $hl) { Get-Content $hl -Tail 20 } ``` ## Signal classification ### A. Disk-driven (most common) Symptoms: low free space, "out of space", install/update failures. - **free % < 10** → CRITICAL. Run Tier 1–6 from `cleanup-tiers.md` immediately. - **free % 10–20** → HIGH. Run Tier 1–3, propose Tier 4–6. Usually manageable. - **free % > 20** → user-perception issue. Audit with a tree sizer (WizTree CLI export, or `Get-ChildItem $env:USERPROFILE -Directory | %{ ... }`) and target specifically. Prefer `Get-CimInstance Win32_LogicalDisk` (uint64 bytes) over `Get-PSDrive` (doubles) for the threshold math. ### B. Memory / commit-driven Symptoms: slow, stuttering, swap/pagefile growing, fans up. - **Commit % is the primary signal** (`\Memory\% Committed Bytes In Use`). Commit charge is total reserved virtual memory (RAM + pagefile). Commit-limit exhaustion is the real Windows OOM — the analog of macOS swap saturation. - **> 85 %** → critical; commit failures / OOM risk. Pair with **available RAM < 5 %** for a true CRITICAL. - **75–85 %** → warning; pagefile is expanding aggressively. - **< 60 %** → healthy. - **Do NOT alarm on "In Use" RAM % alone.** `\Memory\Available MBytes` includes **standby/cache** pages that are instantly reclaimable. Task Manager showing 14/16 GB "in use" with 2 GB available is normal and healthy. Memory Compression (`(Get-MMAgent).MemoryCompression`) extending RAM is also normal. - VM-driven: WSL2 (`vmmem`/`vmmemWSL`) and Docker Desktop can dominate. Check WSL memory cap in `%USERPROFILE%\.wslconfig` (`memory=`). Rule of thumb: keep WSL ≤ ~1/2 host RAM. ### C. BSOD / crash / unexpected shutdown (rare but severe) Symptoms: PC rebooted on its own; "Windows recovered from an unexpected shutdown". ```powershell $since = (Get-Date).AddDays(-14) Get-WinEvent -FilterHashtable @{LogName='System'; ProviderName='Microsoft-Windows-Kernel-Power'; Id=41; StartTime=$since} -EA SilentlyContinue | Select-Object TimeCreated, @{N='BugcheckCode';E={ ([xml]$_.ToXml()).Event.EventData.Data | ?{$_.Name -eq 'BugcheckCode'} | %{$_.'#text'} }} Get-WinEvent -FilterHashtable @{LogName='System'; ProviderName='Microsoft-Windows-WER-SystemErrorReporting'; Id=1001; StartTime=$since} -EA SilentlyContinue | Select-Object TimeCreated, Message ``` Interpret: - **Kernel-Power 41** with `BugcheckCode = 0` → power loss / hard reset (no BSOD). Nonzero `BugcheckCode` (decimal stop code) → a bluescreen occurred. - **WER-SystemErrorReporting 1001** → the BSOD record with the stop code and dump path. - **WHEA-Logger 1** (fatal) / **18** (corrected) → hardware errors. A single corrected error after S0ix resume is usually benign; sustained sequences are not. - Cross-check disk free at the time and whether commit was saturated — a memory/commit crisis can precede an OOM-driven crash. After a crash of this class: confirm it's a one-off (look back 30 days), run cleanup Tier 1–7 for breathing room, install/verify the alerter (`alerting.md`), and if a VM (WSL/Docker/Hyper-V) was the proximate cause, discuss reducing its memory cap. ### D. New crash dump appeared This is the alerter's loudest signal and a leading indicator of (C). Find recent dumps: ```powershell Get-ChildItem "$env:SystemRoot\Minidump" -Filter *.dmp -EA SilentlyContinue | Where-Object LastWriteTime -gt (Get-Date).AddDays(-2) | Select-Object FullName, LastWriteTime, @{N='MB';E={[math]::Round($_.Length/1MB,1)}} Get-Item "$env:SystemRoot\MEMORY.DMP" -EA SilentlyContinue ``` If a fresh dump exists, the system already bugchecked. **Keep the dumps** until triaged — they are forensic evidence (never delete as "cleanup" before the cause is understood). Analyze with WinDbg/`!analyze -v` or `Get-WinEvent` 1001 for the stop code. ### E. "PC just feels slow" Usually commit/memory pressure, thermal throttling, or startup bloat — not a "RAM cleaner" problem. ```powershell # Startup inventory (causes, not symptoms). Autorunsc (Sysinternals) is the CLI form of Autoruns: # autorunsc.exe -accepteula -a * -c -h -s | ConvertFrom-Csv | Sort-Object Entry Get-CimInstance Win32_StartupCommand | Select-Object Name, Command, Location | Format-Table -Auto (Get-MMAgent).MemoryCompression # True = OS compressing pages (normal, not a fault) ``` If nothing stands out, fall back to the memory/commit flow (B). **Do not run "RAM optimizers"** — they purge working sets/standby and make the next access slower, not faster. ## Decision tree ``` ALERT or USER REPORT │ ▼ free % < 20? ──yes──▶ A. Disk-driven (cleanup-tiers.md) │ no ▼ crash event / dump last 24h? ──yes──▶ C/D. Read stop code, keep dumps, propose cleanup + alerter │ no ▼ commit % > 85 AND RAM free < 5%? ──yes──▶ B. Memory/commit-driven, check WSL/Docker caps │ no ▼ thermal / startup ──yes──▶ E. Startup audit, thermal advice (no RAM cleaners) │ no ▼ user perception → tree-size audit, propose targeted cleanup ``` ## What to NOT do at triage - Don't run a broad cleanup reflexively. Identify what's hurting first. - Don't delete crash dumps before the crash is triaged — they're evidence. - Don't delete `pagefile.sys` / `swapfile.sys` / `hiberfil.sys`, touch WinSxS by hand, or run a registry cleaner. None of those are "cleanup". - Don't run "RAM optimizer" tools — purging standby/working sets degrades performance. - Don't `Stop-Process` the top working-set process to "free memory" — corrupts open handles (Docker volumes, DB writes, unsaved work). Ask the user to close it gracefully.
-
-
.gitignore 44 B · in bundle
-
README.md 6.2 KB
# maintaining-windows-health A hands-on playbook skill for **Windows 11** disk cleanup, dev-machine optimization, and proactive health alerting. It is the Windows port of [`maintaining-macos-health`](../maintaining-macos-health/) — same three-layer architecture and the same drift-protection safety invariant, rebuilt around Windows-native tooling. > **Recovery and prevention**, not blind deletion. The skill follows a managed cycle: inventory → classify → delete only with Microsoft-supported tools → verify integrity → keep a rollback path. ## What it does Three layers, mirroring the macOS skill: 1. **Triage** (`references/triage.md`) — classify which signal fired: disk-driven, commit-memory-driven, BSOD/crash, or "feels slow" — with a read-only PowerShell snapshot. 2. **Recovery** (`references/cleanup-tiers.md`, `never-touch.md`, `native-tools.md`) — a 10-tier, risk-ordered cleanup playbook (Storage Sense → discuss-first), a hard blacklist of what never to delete, and the native-tooling safety floor + project-artifact purge map. 3. **Automation / alerting** (`references/alerting.md`, `assets/*.ps1`) — a drift-protected HTML cleanup UI plus a Task Scheduler + BurntToast alerter (3 CRITICAL-only triggers, hysteresis, calibration window). ## The safety invariant ``` scan → build JSON → user picks in the HTML UI → selection JSON → apply deletes ONLY selected_items ``` `assets/apply-cleanup-selection.py` is the only sanctioned way to apply a cleanup. It never hand-rolls `Remove-Item`; it reads the user's picks from the selection JSON and validates every command before running it. The validator is a **Windows rewrite** (not a translation) of the macOS one, because NTFS + PowerShell offer several ways to spell a protected path that a naive check misses: - NTFS path canonicalization (separator unification, `..` collapse, trailing dot/space stripping) like Win32 `GetFullPath` - refusal of UNC/device paths (`\\?\`, `\\.\`), 8.3 short names (`PROGRA~1`), Alternate Data Streams, and non-filesystem providers (`HKLM:`, `Env:`) - a **longest-prefix-wins** allow/deny classifier so carve-outs resolve correctly (`C:\Windows` denied, but `C:\Windows\Temp` allowed; `C:\Users\me` allowed, but `C:\Users\me\.ssh` denied) - a **two-tier wrapper** model: cache tools (npm/docker/dotnet…) are trusted; irreversible tools (`vssadmin delete`, DISM `/ResetBase`, `wevtutil cl`, `pnputil /delete-driver`, `powercfg /h off`) require an explicit protected-override - a refusal of command chaining/injection metacharacters (`; | & \` $( :: > <`) ## Quick start Read-only preflight audit: ```powershell .\assets\Audit-WinHealth.ps1 # drives, Component Store, shadow storage, drivers, CFA, KFM, BitLocker, reparse points, dumps ``` Interactive cleanup (requires Python 3 for the HTML UI): ```powershell python3 .\assets\render-cleanup-plan.py %TEMP%\cleanup-data.json # opens the picker, writes the selection JSON python3 .\assets\apply-cleanup-selection.py %TEMP%\cleanup-selection-<ts>.json # applies only what was picked ``` Install the alerter (non-elevated, interactive session — required for toasts): ```powershell .\assets\Install-WinHealthCheck.ps1 # installs BurntToast, registers the 5-min task, runs once .\assets\Install-WinHealthCheck.ps1 -Test # synthetic disk alert to confirm the toast pipeline ``` ## File map ``` SKILL.md agent entry point (workflows A–D, safety rules, quirks) references/ triage.md which signal fired (read-only snapshot) cleanup-tiers.md 10 risk-ordered tiers, PowerShell blocks never-touch.md hard blacklist + Windows-only dangers native-tools.md safety floor, purge map, validator rules, 3rd-party caveats alerting.md Task Scheduler + BurntToast + ntfy alerter design assets/ win-health-check.ps1 the monitor (PS 5.1 compatible) win-health-check.config.ps1 thresholds Install-WinHealthCheck.ps1 registers the task (interactive session); -Test / -Uninstall Audit-WinHealth.ps1 read-only preflight inventory render-cleanup-plan.py HTML cleanup UI (cross-platform, Python 3) apply-cleanup-selection.py the only sanctioned apply path (Windows validator) test_validate_command.py adversarial validator tests (safety-core gate) ``` ## Key Windows-specific guardrails - **Toasts need an interactive session.** The scheduled task is registered as the logged-on user (`LogonType Interactive`), never SYSTEM — a SYSTEM task's toasts silently no-op. - **`Remove-Item` is permanent** (no Recycle Bin) and **recursive deletes can escape through junctions** — always `-LiteralPath`, never recurse a reparse point. - **OneDrive KFM / Files On-Demand** — deleting redirected/placeholder files propagates to the cloud and every device. - **Clean with commands, not folders** — WinSxS via DISM, Driver Store via pnputil, shadows via vssadmin, logs via wevtutil, hiberfil via powercfg. The registry is never a cleanup target. - **Commit %, not "available MB", is the memory signal** — `Available MBytes` includes reclaimable standby/cache. ## Verification status Authored and statically verified on a macOS machine (no Windows runtime available): - ✅ **Verified here:** PowerShell AST parse of all `.ps1` (via `pwsh`), `py_compile` of both Python scripts, and the adversarial validator suite (`test_validate_command.py`) — pure NTFS string logic that runs on any OS and is the safety-core gate. - ⚠️ **Requires a Windows smoke-test before fully trusting:** real NTFS behavior of `GetFullPath`/8.3/ADS/reparse resolution, toast rendering and AUMID attribution, SYSTEM-vs-interactive session behavior, Task Scheduler registration semantics on S0ix/battery, and the exact output of DISM/vssadmin/wevtutil/pnputil. Gate the alerter and apply-on-real-paths behind this smoke-test. ## Credits & sources Built on official Microsoft Learn guidance for Storage Sense, `cleanmgr`, DISM (WinSxS), `pnputil`, `vssadmin`, `wevtutil`, `powercfg`, `Get-WinEvent`, Task Scheduler, and BurntToast, plus the macOS sibling's incident-validated architecture. See `references/` for inline citations of the behavior each rule depends on. -
SKILL.md 15.8 KB
--- name: maintaining-windows-health description: Hands-on playbook for Windows 11 disk cleanup, dev-machine optimization, and proactive health alerting. Use when the PC is full or slow, when a BSOD / Kernel-Power 41 / crash dump / commit-memory pressure happened, when the user asks to free disk space, audit storage, set up disk/memory alerts, or restore the same monitoring on a new PC. Built around native Microsoft-supported tooling (Storage Sense, cleanmgr, DISM, pnputil, vssadmin, wevtutil, powercfg) as the safety floor, a drift-protected HTML cleanup UI, and a Task Scheduler + BurntToast alerter. Covers dev machines with heavy AI/Docker/WSL workloads. Not for general Windows support, hardware diagnostics, GPU/driver troubleshooting, antivirus/malware removal, Windows Update repair, networking, or app-specific performance problems unrelated to disk or memory pressure. --- # Maintaining Windows 11 Health Recovery and prevention playbook for Windows 11 disk and memory crises. A Windows port of the `maintaining-macos-health` skill: same three layers (triage → tiered recovery → automation/alerting) and the same safety invariant — **scan → JSON → user picks in a UI → apply deletes only what was picked** — but with Windows-native tooling and Windows-specific "never touch" rules. The same playbook works for routine cleanup or first-time setup on a new machine. ## Table of contents - [When to use](#when-to-use) - [Skill layout](#skill-layout) - [Core mental model](#core-mental-model) - [Standard workflows](#standard-workflows) - [A. "Free space NOW" (incident response)](#a-free-space-now-incident-response) - [B. "Set up alerting" (new machine or first time)](#b-set-up-alerting-new-machine-or-first-time) - [C. Alerter stopped working / making noise](#c-alerter-stopped-working--making-noise) - [D. "Uninstall an app cleanly"](#d-uninstall-an-app-cleanly) - [Safety rules (non-negotiable)](#safety-rules-non-negotiable) - [Domain quirks captured](#domain-quirks-captured) - [Outcomes scale](#outcomes-scale) ## When to use Trigger on any of: - Disk free < 20 % or user complains about being out of space - BSOD / unexpected reboot / Kernel-Power 41 / WHEA error / a new crash dump in `C:\Windows\Minidump` - "PC is slow", commit pressure high (`% Committed Bytes In Use` > 85), pagefile growing - User wants to set up monitoring/alerting from scratch - Migration to a new Windows PC → restore the same alerter - General "clean my PC" / "audit storage" / "free space" requests ## Skill layout | File | Use for | |---|---| | `references/triage.md` | First 5 minutes — which signal fired (disk / commit-memory / BSOD-crash / "feels slow"), read-only snapshot | | `references/cleanup-tiers.md` | Tiered cleanup playbook (10 tiers, low-risk → discuss-first), copy-paste-safe PowerShell blocks | | `references/never-touch.md` | Categories that **must not** be deleted even elevated (hard-protected prefixes synced with the validator + Windows-only dangers) | | `references/native-tools.md` | The safety floor: native Microsoft tooling, project-artifact purge marker→target map, the apply-script validator rules, third-party caveats | | `references/alerting.md` | Full alerter design: 3 CRITICAL-only triggers, hysteresis, calibration, Task Scheduler interactive session, BurntToast + ntfy, S0ix/battery | | `assets/win-health-check.ps1` | Production PowerShell monitor (PS 5.1 compatible) | | `assets/win-health-check.config.ps1` | Default thresholds | | `assets/Install-WinHealthCheck.ps1` | Registers the scheduled task in the interactive user session; `-Test` / `-Uninstall` | | `assets/Audit-WinHealth.ps1` | Read-only preflight inventory (drives, Component Store, shadow storage, profiles, drivers, CFA, KFM, BitLocker, reparse points, dumps) | | `assets/render-cleanup-plan.py` | Interactive HTML cleanup-plan UI. Renders categorised checkboxes from a JSON of scan findings, serves on `127.0.0.1:18347`, opens the browser, waits for the user's selection, writes it to `%TEMP%\cleanup-selection-<ts>.json`. Used by Workflow A. **Requires Python 3.** | | `assets/apply-cleanup-selection.py` | The **only sanctioned way** to apply a cleanup selection. Reads `selected_items` from a selection JSON and executes each item's `command` via PowerShell. Windows-rewritten validator (NTFS canonicalization, deny/allow longest-prefix, provider/UNC/ADS/8.3/chaining refusal, two-tier wrappers) + operations log. Supports `--dry-run`. | | `assets/test_validate_command.py` | Adversarial unit tests for the validator — the safety-core gate. Runs on any OS. | Read the relevant reference before acting. Do NOT operate from memory of these files — the details are calibrated to Windows-specific failure modes and small changes break safety. ## Core mental model 1. **Native tooling is the safety floor.** Unlike macOS (Mole), Windows has no mature community safety tool — so Storage Sense / Cleanup recommendations / `cleanmgr` / DISM do the heavy lifting, and the apply-script validator is the *only* line behind project-artifact purge. Use the supported command for anything the OS maintains itself. 2. **Clean with commands, not by deleting folders.** WinSxS → DISM; Driver Store → pnputil; shadow copies → vssadmin; Event Logs → wevtutil; hiberfil → powercfg. Hand-deleting these corrupts the OS. 3. **Commit %, not "available MB", is the memory signal.** `Available MBytes` includes reclaimable standby/cache; commit-limit exhaustion is the real Windows OOM. 4. **Drift protection via the selection JSON.** The HTML UI writes exactly what the user picked; `apply-cleanup-selection.py` deletes only `selected_items`. Never hand-roll `Remove-Item` in the apply phase. 5. **Escalate conservatively.** Start zero-risk (Storage Sense, caches, Recycle Bin), only reach project artifacts, Docker/WSL VHDX, Component Store, and elevated/discuss-first tiers if needed. ## Standard workflows ### A. "Free space NOW" (incident response) 1. **Triage** — read `references/triage.md`, identify which signal fired and how urgent. Run `assets/Audit-WinHealth.ps1` (read-only) for the preflight inventory (drives, Component Store, shadow storage, drivers, **CFA / OneDrive KFM / BitLocker / reparse points**). 2. **Snapshot baseline** — record free GB on the target drive. 3. **Run all scans, don't delete yet** — work the tiers in `references/cleanup-tiers.md` in *inventory* mode (list candidates, sizes, ages), including the large-file visibility pass. Capture everything before opening the browser; deletion comes only after the user picks via the UI. The cleanup UI should be a single final questionnaire containing safe cleanup candidates, protected/discuss-first items, and large suspicious user/workload files together, not a sequence of separate UI rounds. 4. **Python gate** — the cleanup UI needs Python 3. Check for a real interpreter (`py -3 --version`, or a `python.exe` that isn't the Microsoft Store alias). **If Python is missing, ask the user for permission to install it** (`winget install Python.Python.3.12`), then continue. Do not silently skip the UI. 5. **Resolve unknown items before building JSON** — for every candidate > 500 MB you cannot explain in one sentence (unfamiliar app/folder, vendor cache, VM/VHDX, model weights), research it first: check `references/never-touch.md`, then delegate a quick lookup to the `web-searcher` subagent ("what is `<path>` on Windows 11, safe to delete in 2026"). Write a concrete `description` (1–3 sentences in the user's language) into the item. **Never show vague placeholders like "unknown".** 6. **Show large-but-not-safe items too** — any explainable item > 500 MB that is user-owned or workload-owned but not a safe cache must still appear in the same final UI, normally `protected: true`, `default_selected: false`, with a concrete `warning`. Examples: old `.7z/.zip/.iso` archives, `.gguf`/`.safetensors` model weights, project/game assets, app diagnostic dumps, profiler snapshots, synced-folder media, and VHDX files. Visibility is required because only the user can know whether these are still needed. Do not include hard-protected system internals as bare-delete candidates; show them only through supported tools, or omit deletion entirely if no supported action exists. 7. **Build one data JSON** — every candidate becomes a structured `item` (id, label, path, size_bytes, age_days, kind, **PowerShell `command`**, mandatory `description`, optional `protected` + `warning`). Use the schema in `assets/render-cleanup-plan.py`. Irreversible-tool commands (DISM `/ResetBase`, `vssadmin delete`, `wevtutil cl`, `pnputil /delete-driver`, `powercfg /h off`) should be marked `protected: true`. Do not open the UI until this unified JSON contains both safe default-selected items and protected unchecked items. 8. **Render and open the cleanup UI**: ```powershell python3 <skill>\assets\render-cleanup-plan.py %TEMP%\cleanup-data-<ts>.json ``` It serves on `127.0.0.1:18347`, opens the browser, and **blocks** until Submit/Cancel. On submit it writes `%TEMP%\cleanup-selection-<ts>.json`. Tell the user out loud: "браузер открыт — поставь галочки, нажми Submit, потом пингани меня." Then **stop and wait**. 9. **After the user pings** — read the selection JSON, render their choices back in chat (categories, item list, total GB, any protected overrides flagged), and **ask one explicit confirmation** before deleting. 10. **Apply via the helper — never hand-rolled `Remove-Item`**: ```powershell python3 <skill>\assets\apply-cleanup-selection.py %TEMP%\cleanup-selection-<ts>.json # add --scan-root D:\projects to allow bare deletes on an external dev drive ``` It reads `selected_items`, validates each `command` (NTFS canonicalization, deny/allow longest-prefix, provider/UNC/ADS/8.3/chaining refusal), skips protected items not in `protected_overrides`, runs each via `powershell.exe`, and logs to `%LOCALAPPDATA%\win-health\operations.log`. `--dry-run` previews. **The selection JSON is the single source of truth.** 11. **Post-check** — report the free-space delta, then `Dism /Online /Cleanup-Image /CheckHealth` + `sfc /scannow` to confirm nothing was broken. Stop at the goal. Hard-protected items (per `references/never-touch.md`) must always appear in the UI with `"protected": true` + a concrete `warning` — the UI dims them and requires a per-item confirm before they can be checked. Never omit a protected item user data depends on; visibility teaches the surrounding risk. ### B. "Set up alerting" (new machine or first time) Run a **non-elevated** PowerShell as the user who should receive alerts (interactive session — required for toasts): ```powershell cd <skill>\assets .\Install-WinHealthCheck.ps1 # installs BurntToast, copies script+config, registers the task, runs once ``` Then read `references/alerting.md` for tuning. The task is registered in the interactive user session (NOT SYSTEM — that silently swallows toasts), every 5 minutes, `-StartWhenAvailable`, battery-friendly. The first 7-day calibration window is silent (logs only). Verify with `.\Install-WinHealthCheck.ps1 -Test`. ### C. Alerter stopped working / making noise Read `references/alerting.md` § Troubleshooting. Common causes: - Task running as SYSTEM / "whether user is logged on or not" → toasts silently never render. Re-run the installer (interactive principal). - BurntToast not installed → toast attributed to "PowerShell" or absent. `Install-Module BurntToast -Scope CurrentUser`. - Laptop never checks → was AC-only / paused in sleep. Re-run installer (battery flags + `-StartWhenAvailable`). - Constant alerts during heavy work → create `%LOCALAPPDATA%\win-health\silent`, raise thresholds, or increase hysteresis. ### D. "Uninstall an app cleanly" Prefer `winget uninstall --id <App.Id>` (clean, supported). For apps with stubborn leftovers, BCUninstaller (review the leftover list) is the dev-friendly option. Never hand-delete `Program Files` install dirs or registry keys to "remove" an app — that orphans the MSI/uninstall state. Always confirm before removing user data folders. ## Safety rules (non-negotiable) 1. **Never delete without dry-run + user confirmation** for any tier ≥ 5 or any elevated operation. 2. **Never bypass `references/never-touch.md`** — even if the user explicitly asks. Push back, explain the consequence. 3. **Clean with commands, not folders.** WinSxS via DISM, Driver Store via pnputil, shadows via vssadmin, logs via wevtutil (export before clear), hiberfil via powercfg. Never `Remove-Item` these. 4. **The registry is never a cleanup target.** No registry cleaners (Microsoft-unsupported). The validator refuses registry-provider deletes. 5. **Apply phase reads only the selection JSON.** Never hand-roll `Remove-Item` or hard-code paths from the earlier scan when applying — that's how you delete items the user unchecked. Use `assets/apply-cleanup-selection.py`, which iterates `selected_items` only. 6. **`Remove-Item` is permanent** (no Recycle Bin) and recursive deletes can escape through junctions — always `-LiteralPath`, never recurse a reparse point. Prefer move-to-quarantine when the user is unsure. 7. **No auto-cleanup tied to alerts.** Alerts notify; the human decides. 8. **Keep crash dumps until triaged** — they're forensic evidence, not cleanup. ## Domain quirks captured - **SYSTEM session can't show toasts.** A scheduled task as SYSTEM executes but its toasts silently no-op (Session 0 has no desktop). Register the task in the interactive user session — the Windows analog of the macOS "osascript → Script Editor" trap. - **`Remove-Item -Recurse` follows junctions** and can delete the target's contents — the classic data-loss bug. Dev trees are full of junctions (npm/pnpm store, Docker, WSL). Use `-LiteralPath`; never recurse a `FILE_ATTRIBUTE_REPARSE_POINT`. - **`Remove-Item` bypasses the Recycle Bin** — deletion is immediate and permanent, unlike dragging to the bin. - **OneDrive KFM** redirects Desktop/Documents/Pictures into OneDrive — deleting there propagates to all devices. Files On-Demand placeholders (`RECALL_ON_DATA_ACCESS`/`OFFLINE`) are cloud originals; deleting the stub deletes the cloud file. - **Controlled Folder Access** blocks deletes in Documents/Pictures/Desktop with Access Denied from Defender — distinguish from a real error. - **`Available MBytes` includes standby/cache** — high "available" doesn't mean healthy; commit-limit exhaustion is the real OOM signal. - **`pagefile.sys` and `swapfile.sys` are two different files**; `C:\Windows\Installer` + `Package Cache` break MSI repair if deleted; **Prefetch** should not be cleaned (myth). - **Modern Standby (S0ix)** makes wake timers unreliable and hides S3 — normal, not a fault. The task pauses in sleep and catches up on wake via `-StartWhenAvailable`. - **General rule**: if you can't describe a folder in one sentence (especially > 500 MB), don't guess — delegate a `web-searcher` lookup before writing the item's `description`. If you can describe it but it may be user data, still show it in the single final cleanup UI as protected and unchecked by default. ## Outcomes scale A representative recovery on a dev machine that hit ~8 % free under heavy AI/Docker/WSL load: - Largest single contribution: project build artifacts (`node_modules`, `bin`/`obj`, `target`, `.next`) via Tier 7 purge. - WSL2 / Docker `*.vhdx` compaction (not deletion): often 10–40 GB reclaimed. - Component Store cleanup via DISM after many cumulative updates: a few GB. - Package-manager caches (npm/pnpm/NuGet/pip/cargo/gradle): a few GB. - `Downloads` review (old installers, ISOs): variable, often 10–20 GB. - Elevated tier (Windows.old after rollback check, Delivery Optimization, exported+cleared logs): variable. Active alerter installed with a 7-day calibration window; verified via the synthetic disk-trigger test before going live. Numbers scale with workload and disk size — light users see less; heavy AI/Docker/WSL/IDE users see more.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.