Each run queries a Chrome DevTools Protocol (CDP) endpoint for page tabs and compares each tab's title and URL with the previous run. When that pair has stayed unchanged for the configured threshold, the script reports the tab as idle; with --close, it asks Chrome to close it. It runs in dry-run mode by default, protects the first real page in Chrome’s list, and skips URLs matching --keep patterns.
Python 3.8+ is required; no packages needed. From the cloned repository,
run this first (on Windows, use py -3 in place of python3):
python3 close_idle_tabs.py --helpHelp prints the available options and exits without contacting Chrome. The script
uses only Python's standard library and Chrome's existing CDP HTTP endpoints
(/json/list and /json/close/{id}). Python below 3.8 gets a short
"needs Python 3.8 or newer" message. Chrome with debugging enabled is needed for
actual tab observations; see the launch commands below.
Check python3 --version. If Python is missing or older than 3.8, install a
supported Python 3 through your distribution's package manager. You can run
python3 close_idle_tabs.py directly; neither pip nor a virtual environment is
required.
Check python3 --version before starting. The Python supplied by macOS developer
tools can be 3.9.6, which works; some Macs have an older version or no Python 3.
For a newer version, use the Python.org macOS installer,
then open a new Terminal and check python3 --version again. Run
python3 close_idle_tabs.py --help from the clone.
Use the Python launcher, not python3, which can be a Microsoft Store stub:
py -3 --version
py -3 close_idle_tabs.py --helpIf py is unavailable or reports a version below 3.8, install Python 3 from
Python.org for Windows with the
launcher option enabled, reopen PowerShell, and repeat the check. Use py -3
for the script and test commands throughout this README.
If you want a cdpidletabs command, use a virtual environment. The direct script
above is the first-class, no-admin path. On Debian/Ubuntu, venv needs
python3-venv. If creation fails because ensurepip is missing, delete the
partial environment with rm -rf .venv (only the environment you just created)
and continue with python3 close_idle_tabs.py --help and the direct script.
If you prefer to retry installation, install python3-venv with your package
manager (for example sudo apt install python3-venv, requiring administrator
access), delete that partial .venv, then repeat the optional commands below.
Linux/macOS:
python3 -m venv .venv
. .venv/bin/activate
python -m pip install .
cdpidletabs --helpWindows PowerShell (activation is optional):
py -3 -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install .
cdpidletabs --helpIf PowerShell blocks activation, use .\.venv\Scripts\python.exe -m pip install .
and .\.venv\Scripts\cdpidletabs.exe --help without changing execution policy.
If you already use pipx, pipx install . is another optional way to install the
command; pipx itself and package building need installation tools and downloads.
Set CDP_ENDPOINT in the environment to the browser's HTTP debugging endpoint,
or pass it with --endpoint.
Run the script from the same working directory each time so its default .close_idle_tabs_state.json observation file is reused (cron and systemd must set the same working directory, or every run starts fresh). The file stores target IDs, SHA-256 digests of title and URL pairs, and timestamps; it does not store page titles or URLs. To check the command from a fresh checkout, run python3 close_idle_tabs.py --help; it prints the available options and exits without contacting Chrome.
Automated tests use mocked HTTP responses and temporary state files:
python3 -m unittest discover -s tests -qMIT licensed, pre-release. No package or public release has been published. See SECURITY.md before enabling browser access.
Choose a free port and a new profile directory for your own Chrome
instance. The examples use 9222; the browser owner can pick any available TCP
port. Change both Chrome's --remote-debugging-port and the script's endpoint
when choosing another. Port 9222 is a common convention, not a required port,
and may already belong to someone else on a shared machine.
Before launching, check the example port (use py -3 on Windows). If this fails
with "address already in use", pick a different port; do not attach to or stop
the existing browser:
python3 -c "import socket; s=socket.socket(); s.bind(('127.0.0.1',9222)); s.close(); print('Port 9222 is available')"Use a dedicated, non-default --user-data-dir: Chrome 136 and later ignore
remote debugging flags on the default profile directory.
Keep the address on loopback (127.0.0.1). Do not use your everyday profile.
Install Chrome from Google's download page
if it is missing: on Debian/Ubuntu select the .deb and install it with your
package manager; on macOS move the app to Applications; on Windows run the
installer. Installation may require administrator access. For Linux without
that access, Chrome for Testing
offers a ZIP that runs from your own folder; see the exact no-admin commands
below. System shared libraries are still required.
The graphical command needs a desktop session with a working display
(DISPLAY on X11). google-chrome is the usual installed binary name:
chrome_profile=$(mktemp -d "${TMPDIR:-/tmp}/cdpidletabs-profile.XXXXXX")
google-chrome --no-first-run --remote-debugging-port=9222 --remote-debugging-address=127.0.0.1 \
--user-data-dir="$chrome_profile"--no-first-run skips Linux's initial setup dialog, which can otherwise pause
startup before the debugging endpoint is available in a new profile.
Over SSH without a display, use this headless alternative (no visible window):
chrome_profile=$(mktemp -d "${TMPDIR:-/tmp}/cdpidletabs-profile.XXXXXX")
google-chrome --headless=new --remote-debugging-port=9222 \
--remote-debugging-address=127.0.0.1 --user-data-dir="$chrome_profile"For Linux x86-64, these commands need curl, unzip and Python 3. They
select Google's current Stable ZIP, download it into a new folder and extract
with unzip, which preserves Unix executable permissions. Do not substitute
Python's zipfile extraction: it drops those permissions. Explicitly restore
the executable bits on Chrome and its helper binaries as well; a non-executable
chrome_crashpad_handler causes a posix_spawn ... Permission denied crash.
Run from the repository in a shell with a free port (9222 here):
cft_dir=$(mktemp -d "${TMPDIR:-/tmp}/cdpidletabs-cft.XXXXXX")
curl -fL https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json -o "$cft_dir/versions.json"
cft_url=$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(next(x["url"] for x in d["channels"]["Stable"]["downloads"]["chrome"] if x["platform"] == "linux64"))' "$cft_dir/versions.json")
curl -fL "$cft_url" -o "$cft_dir/chrome.zip"
unzip -q "$cft_dir/chrome.zip" -d "$cft_dir"
chmod +x "$cft_dir/chrome-linux64/chrome" "$cft_dir/chrome-linux64/chrome_crashpad_handler" "$cft_dir/chrome-linux64/chrome_sandbox"
chrome_profile="$cft_dir/profile"
"$cft_dir/chrome-linux64/chrome" --headless=new --no-first-run --no-default-browser-check --remote-debugging-port=9222 --remote-debugging-address=127.0.0.1 --user-data-dir="$chrome_profile" >"$cft_dir/chrome.log" 2>&1 &
chrome_pid=$!Wait a few seconds, then require a successful version response before running
this tool. If it fails, read "$cft_dir/chrome.log"; do not infer startup from
the background command's exit status. These flags worked with the normal Chrome
sandbox; they do not require --no-sandbox.
curl --fail http://127.0.0.1:9222/json/version
python3 close_idle_tabs.py --port 9222Keep these variables in the same shell. When finished, stop only the instance you just launched and delete only its new download/profile folder:
kill "$chrome_pid"
wait "$chrome_pid" || true
rm -rf -- "$cft_dir"In Terminal, launch a separate Chrome app instance:
chrome_profile=$(mktemp -d "${TMPDIR:-/tmp}/cdpidletabs-profile.XXXXXX")
open -na "Google Chrome" --args --remote-debugging-port=9222 \
--remote-debugging-address=127.0.0.1 --user-data-dir="$chrome_profile"The app binary is also usable directly, including over SSH into a Mac with a logged-in graphical session (use a fresh profile when trying this alternative):
chrome_profile=$(mktemp -d "${TMPDIR:-/tmp}/cdpidletabs-profile.XXXXXX")
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
--remote-debugging-port=9222 --remote-debugging-address=127.0.0.1 \
--user-data-dir="$chrome_profile"Run this in a normal desktop PowerShell window on the Windows machine:
$chromeProfile = Join-Path $env:TEMP ("cdpidletabs-profile-" + [guid]::NewGuid())
& "$env:ProgramFiles\Google\Chrome\Application\chrome.exe" --no-first-run --remote-debugging-port=9222 --remote-debugging-address=127.0.0.1 "--user-data-dir=$chromeProfile"If Chrome is installed elsewhere, replace the executable path with its actual
location. $chromeProfile is a new disposable directory, not your regular profile.
Check /json/version from a second PowerShell window as shown below.
The current Windows user must also be logged into the desktop. This route opens
a visible Chrome window there and survives the launching SSH session ending.
Use your own free port and a new task/profile. The block creates no trigger or
recurring schedule, and does not require a password or an elevated principal.
Use the shipped scripts/windows-launch.ps1 instead
of pasting a multiline launch block. From the repository, a single command
creates and runs a uniquely named task, waits for /json/version, and prints
the task name and disposable profile for later cleanup:
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\windows-launch.ps1 -Port 9222The ProfileDir parameter accepts a new, nonexistent disposable directory; -ChromeExe
accepts another installed Chrome path. The script rejects existing profiles,
task names and occupied ports. It does not run the Python tool or delete the
task on success. Copy the printed task name/profile: variables and returned
objects do not survive between SSH calls.
For a complete create/run/check/delete walkthrough, save the following block
to a file named launch.ps1 in the repository root (or create the file locally
and transfer it with scp). Do not pipe or paste the multiline block into
powershell -Command - over SSH: that can exit 0 silently without creating a
task. Run the saved file from the repository with:
powershell -NoProfile -ExecutionPolicy Bypass -File launch.ps1Complete contents of launch.ps1 (this walkthrough closes its own browser and
removes its task/profile after the dry run, including on startup failure):
$ErrorActionPreference = 'Stop'
Set-Location $PSScriptRoot
$chromePort = 9222
$chromeProfile = Join-Path $env:TEMP ("cdpidletabs-profile-" + [guid]::NewGuid())
$taskName = "cdpidletabs-temp-" + [guid]::NewGuid()
if ((Test-Path -LiteralPath $chromeProfile) -or (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue)) { throw 'Choose a new profile and task' }
try {
$launchOptions = @{ Port = $chromePort; ProfileDir = $chromeProfile; TaskName = $taskName }
$launch = & .\scripts\windows-launch.ps1 @launchOptions
$launch.Version
Get-ScheduledTask -TaskName $taskName | Select-Object TaskName, State
Invoke-RestMethod "http://127.0.0.1:$chromePort/json/version"
py -3 close_idle_tabs.py --port $chromePort
if ($LASTEXITCODE -ne 0) { throw 'Python check failed' }
} finally {
if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) {
Stop-ScheduledTask -TaskName $taskName
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
}
$profileArgument = '--user-data-dir=("?)' + [regex]::Escape($chromeProfile) + '\1(?=\s|$)'
Get-CimInstance Win32_Process -Filter "name = 'chrome.exe'" |
Where-Object { $_.CommandLine -and $_.CommandLine -match $profileArgument } |
ForEach-Object { Stop-Process -Id $_.ProcessId -ErrorAction SilentlyContinue }
Start-Sleep -Seconds 2
if (Test-Path -LiteralPath $chromeProfile) { Remove-Item -LiteralPath $chromeProfile -Recurse -Force }
if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) { throw 'Own task remains' }
if (Test-Path -LiteralPath $chromeProfile) { throw 'Own profile remains' }
if (Get-NetTCPConnection -LocalPort $chromePort -State Listen -ErrorAction SilentlyContinue) { throw 'Port still has a listener; investigate without stopping other browsers' }
'Cleanup verified: own task/profile removed, port has no listener.'
}To clean up a browser launched with the standalone script, save the finally
body as cleanup.ps1, preceded by $ErrorActionPreference = 'Stop' and explicit
assignments to $taskName, $chromeProfile and $chromePort using the printed
values. Run it with powershell -NoProfile -ExecutionPolicy Bypass -File cleanup.ps1.
Never stop all Chrome processes. The desktop command above creates no task;
stop only its profile-matched Chrome and remove that disposable profile.
An ordinary Windows SSH shell has no interactive desktop. In verification,
both direct headless launch and Start-Process exposed a listener while the
launching SSH session remained open, then lost it after disconnect. Other
remote-shell attempts returned status 0 with no listener: an exit code alone is
not proof of startup. Keeping a TTY SSH session open is not a persistent launch
route. Use the interactive task above for remote-shell use. A logged-out desktop
and a non-interactive task principal are not covered by these instructions.
In another terminal, confirm that your newly launched Chrome responds:
curl http://127.0.0.1:9222/json/version
python3 close_idle_tabs.py --endpoint http://127.0.0.1:9222For a local browser, python3 close_idle_tabs.py --port 9222 is shorthand for
that endpoint. --port accepts 1–65535 and overrides CDP_ENDPOINT; do not
combine it with --endpoint. Use --endpoint for an explicit HTTP(S) base URL.
The script does not assume port 9222: supply --port, --endpoint, or
CDP_ENDPOINT each run. These options select an existing endpoint; they do not
launch Chrome or reserve a port.
PowerShell equivalent:
Invoke-RestMethod http://127.0.0.1:9222/json/version
py -3 close_idle_tabs.py --endpoint http://127.0.0.1:9222The version request must return Chrome's JSON before continuing. If no listener
is available, the script prints NOT CHECKED: nothing is listening on port 9222
(followed by a skipped-run explanation). Its exit status is still 0 for
scheduled-use compatibility; this is not a successful endpoint check. Fix
the launch or port and repeat the version request. DNS/timeout failures also
print NOT CHECKED, without claiming that there is no listener.
Or set CDP_ENDPOINT=http://127.0.0.1:9222 in a POSIX shell with
export CDP_ENDPOINT=http://127.0.0.1:9222, or in PowerShell with
$env:CDP_ENDPOINT = 'http://127.0.0.1:9222'. Reuse the same working directory
and profile for repeated observations. For a disposable test, stop only the
Chrome instance you launched and remove its profile directory after it exits.
The examples/ folder contains optional user-unit templates that report only
(dry run, --quiet); to close tabs, add --close to ExecStart after reviewing
the reports. Installation is manual:
- Create the working directory the service expects:
mkdir -p ~/.local/share/cdpidletabs. - Create
~/.config/cdpidletabs/environmentcontaining one line,CDP_ENDPOINT=http://127.0.0.1:9222(replace 9222 with your browser's port). - Copy both templates to
~/.config/systemd/user/ascdpidletabs.serviceandcdpidletabs.timer, replacing@VENV_BIN@in the service with thebindirectory of your virtual environment (for example<clone-directory>/.venv/bin). - Run
systemctl --user daemon-reloadandsystemctl --user enable --now cdpidletabs.timer.
The timer runs the service every 60 seconds, starting a minute after it is enabled.
With CDP_ENDPOINT set, this reports candidates for tabs unchanged for ten minutes and exempts the example documentation path:
python3 close_idle_tabs.py --idle-seconds 600 --keep 'https://example.com/docs/*'The first run records the current tab states. Invoke it repeatedly, at a shorter
interval than the idle threshold, to accumulate unchanged time. It prints target
IDs and their status. After reviewing dry-run output, add --close to request
closure of eligible pages. The guard always leaves at least one real page from
the fetched list open, if one exists: it protects the first real page in
Chrome's list, usually the most recently activated tab. Internal pages never
satisfy this guard. about:blank is protected only when no real page exists;
when there are only internal pages, there is no real page to protect.
--idle-seconds accepts integers with a minimum of 1 second (default: 600);
0 is rejected before checking for an endpoint. There is no maximum: the
threshold is compared with elapsed time since the saved observation, not with
how long this single invocation runs. Above 31536000 seconds (365 days), a
one-line notice on stderr (also under --quiet) warns that eligibility needs
observations this old in saved state. Reuse the same default state file or
--state-file across runs; a fresh state cannot immediately qualify. A very
large threshold may therefore never be reached in practice. Eligible dry-run output such as idle for 4s (threshold 2s); would close
describes time already observed, not an additional wait.
If the endpoint is unreachable (connection, DNS or timeout), the run is skipped,
observation state stays unchanged, and it exits 0 with one NOT CHECKED notice. Add
--quiet for scheduled runs to suppress that notice and normal reports. Invalid
JSON, HTTP rejection, bad arguments and state-file errors remain real errors;
--quiet does not hide them. The portable service template uses --quiet.
CDP does not provide a tab's last-active time here, so the script treats an unchanged title and URL across timer runs as idle. A page can still be in use while unchanged, and list order is only a preference for the guard, not proof that a tab is idle: use --keep to protect pages you need. Agent tabs have no owner in this interface, so the script cannot know which agent opened a tab or whether that agent's session is done. Use keep patterns for URLs that should stay open.
The threshold also gives each tab a grace period after the last observed title
or URL change: --idle-seconds 300 waits five minutes after that change. It is not
a last-navigation timestamp. Reloading the same title/URL, background activity,
and navigating away and back between runs can be missed. Time while Chrome is
unreachable still counts since the last observation. Choose a conservative
threshold and keep important pages explicitly.
Ordinary pages keep their idle clock between runs when the same state file is
reused. --state-file is optional: by default the script already reuses
.close_idle_tabs_state.json in the working directory. A new/missing state file,
a different working directory with no saved state, a changed target ID, or an
observed title/URL change starts a fresh clock. Pages protected by --keep, the
real-page guard, or the default internal-page skip restart their clock each run;
after losing that protection they must reach the full threshold again. Dry runs
also save these observations. --state-file /path/to/state.json makes reuse
explicit across working directories; it does not measure activity between runs.
No real-browser closure test is part of the automated suite. Before relying on it, use a disposable Chrome profile with expendable tabs, review dry-run output, then confirm that only eligible tabs close and a real page stays open.
Internal pages are skipped by default: chrome://, chrome-untrusted://,
devtools://, chrome-extension://, and chrome-search:// (including first-run,
sign-in and new-tab pages). They never count as the real page the guard protects,
even with --keep. To consider closing internal/extension pages, explicitly
use --include-internal; keep patterns still apply. This opt-in can request
closure of all internal pages when no real or blank page exists. Ordinary web,
file and data pages are real pages. A blank tab is only a fallback when there
are no real pages. Chrome usually lists the most recently activated page first;
the tool uses the first real page in the fetched order without inferring a
last-active timestamp. It protects that page even when other real pages are
kept, still within their grace period, or fail to close.
--close prints "close requested" when Chrome accepts the HTTP request;
it does not confirm disappearance from /json/list. Ordinary pages can close
asynchronously, and internal pages such as "Sign in to Chrome" can remain
listed despite an accepted request. Check the dedicated instance's /json/list
afterwards when verifying actual closure. An accepted request removes that
target's saved observation; if it is still listed on the next run, its idle clock
starts again. Failed requests retain their observations. The guard never
requests closure of its chosen page, but only sees the fetched list: it cannot prevent Chrome or
another user closing that page concurrently.