- name
- park-workspace
- description
- Free the RAM held by idle Claude sessions in cmux workspaces without closing the workspaces, so they stay visible as a TODO list. Parks a single workspace, a whole cmux window, or a named set — stopping the claude session plus its dev servers and test browsers — and restores the exact session later with full history. Use when the user says "zaparkuj tenhle workspace", "zaparkuj celé okno", "park this workspace", "park the window", "unpark", "odparkuj", "co je zaparkované", or complains that idle cmux sessions eat RAM ("claude sessions mi žerou paměť", "mám moc otevřených workspaces", "potřebuju uvolnit RAM ale nechci nic zavírat"). Prefer this over cmux's built-in agent hibernation, which has lost live sessions before.
- allowed-tools
- ["Bash(./park.py:*)","Bash(park:*)","Bash(cmux list-windows:*)","Bash(cmux list-status:*)","Bash(cmux workspace list:*)","Bash(cmux read-screen:*)","Read"]
# park-workspace
Idle Claude sessions cost ~150-350 MB each. With 40+ open workspaces that is
several GB doing nothing — but closing them loses the TODO value of an open
workspace, and cmux's own agent hibernation has lost live sessions and worktrees
before.
Parking is the middle path: **the workspace stays open, only its processes go.**
`park.py` is stdlib-only Python 3.9+ and needs the `cmux` CLI on PATH. The
commands below assume `park` itself is on PATH — link it once:
```bash
ln -sf ~/.claude/skills/park-workspace/park.py ~/.local/bin/park
```
Without that link, call it as `~/.claude/skills/park-workspace/park.py <cmd>`.
```
park interactive picker [park pick <filter>]
park ls dashboard grouped by window [--json]
park . park the workspace you are in
park park <target>... park workspace(s) [--dry-run] [--force] [--kill-anyway]
park park --idle 3d park everything idle that long (asks first)
park park --all park everything parkable (asks first)
park . --close-tab park, then close the tab park itself runs in
(what the ⌥⇧P action uses)
park unpark <target>... resume a parked workspace
park show <target> dump the ledger entry
park forget <target> drop a stale ledger entry without resuming
park doctor verify every parked entry is still restorable
park repaint put back the pill and prefill a cmux restart
wiped [--dry-run]
park rekey re-point entries after cmux regenerated its
workspace uuids — run BEFORE rebuild [--dry-run]
park rebuild recreate workspaces cmux lost [--dry-run]
```
Targets: `workspace:12` · `<uuid>` · `<title substring>` · `window:2` · `.` (current)
`.` resolves through `CMUX_WORKSPACE_ID` and **refuses** when that is unset.
There is no such thing as "the current workspace" outside a cmux pane: cmux's
`selected` flag is per *window*, so falling back to it made `park park .` from
a plain Terminal park one workspace in every window at once.
Reported sizes are the RSS of the **whole descendant tree**, because that is
what `kill_tree` takes. Counting only the matched roots undercounted badly — a
workspace reporting 67 MB was really holding 208 MB, and across 38 live
workspaces the totals were 4.5 GB reported against 5.2 GB actually killed.
(RSS double-counts pages shared between processes, so the memory genuinely
returned to the system is somewhat lower; this is the set of processes that go.)
`ls` groups by window — window = project in this user's mental model — and gives a
per-window "parkable" total, which is the number to quote when offering to park.
`--json` emits the same rows for scripting.
A `⚠` row is a **parked entry with a session running in it**. Something resumed
the workspace behind park's back — a hand-run `cmux restore` after a reset does
exactly this — and the row used to claim `❄ parked` and report the megabytes it
freed days ago. It now shows the memory the workspace is holding *now* and
counts as live — `--json` carries both numbers, `bytes` for what it holds and
`freed_bytes` for what the entry recorded at park time. `park unpark` on it
clears the stale entry without resuming anything; `park forget` does the same if
you want the session left alone.
**Exit codes.** `0` when every target reached the state you asked for — an
already-parked workspace or an already-unparked one counts — and `1` when any
was refused. `park doctor` exits `1` when any entry needs attention. Nothing
about the shortcut cares, but a health check or a `park . && …` has no other way
to find out, and a tool that always exits 0 reads as "fine".
`--idle <30m|6h|3d|2w>` and `--all` pick their own targets: everything with a
live session that is not parked, not mid-turn, and — for `--idle` — whose last
submitted prompt is at least that old. A workspace cmux has **no** timestamp for
is left out rather than treated as idle forever. Both ask for a typed `yes`
first (`--dry-run` shows the list without asking), neither accepts named targets,
and **neither can be combined with `--kill-anyway`**: choosing in bulk and
waiving the guards that protect a running turn are each defensible and together
are not. Every selected workspace still goes through the full guard chain, so
this narrows the list and never widens what park is willing to kill.
## The picker
Bare `park` opens a curses list grouped by window. **Enter toggles the row under
the cursor** — a live row freezes, a `❄` row thaws — and the work runs on a
worker thread so the list stays live and the row shows its own progress
(`⠏ freezing…` / `unfreezing…`, with an in-flight count in the header). A row
already in flight ignores further presses, and `q` refuses to quit while
anything is mid-kill. A turn in flight shows `●` and cannot be toggled.
`park pick <substring>` pre-filters — with 40 workspaces, scrolling is the slow
part.
`f` jumps to the workspace under the cursor (`focus-window` + `select-workspace`)
and **leaves the picker open** — glancing at a workspace and coming straight back
to the list is the normal move, and closing it every time made `f` useless for
that.
The list **re-scans itself every ~30 s while its tab is on screen, and not at
all while it is not**; `r` forces a scan, and coming back to the tab triggers
one immediately (throttled to one per 3 s, or a flurry of tab switches queues
scans behind each other). A scan is ~1.5 s of socket round-trips, so it runs on
a background thread and the result is only swapped in when nothing is
mid-freeze: worker threads mutate the very row dicts on screen, and replacing
them mid-kill would strand those mutations. The cursor stays on the same
workspace across a refresh — a list that jumps while you are aiming at a row is
worse than one that does not refresh.
Off screen there is nobody to read the list and nothing to miss (the picker has
no notifications), so scanning there is pure waste — measured live: 155 child
processes per 45 s on screen, **0 per 60 s off screen**, and a scan within 20 s
of coming back. The signal is the terminal's own focus reporting (DECSET 1004),
which cmux fires for all three ways a tab leaves the screen: another workspace
in the window, another cmux window, another app entirely. Two things about
reading it back:
- Handle **both** deliveries. With `TERM=xterm-ghostty` — what cmux sets —
ncurses already knows focus reporting and swallows the sequence, returning the
named keys `kxIN`/`kxOUT` (match on `curses.keyname`, the numeric codes are
assigned at runtime). Parsing the raw `ESC [ I` bytes alone therefore fires
*never*, silently, and the picker just keeps scanning. Terminals whose
terminfo lacks it deliver the raw sequence, whose leading ESC would otherwise
read as the quit key.
- Turn the mode **off on every exit path**, crash included. Left enabled, the
shell that inherits the tty gets `[I`/`[O` typed into its prompt on every
window switch.
`a` toggles the whole window and is the one bulk action, so it still asks for a
y/N confirmation. That gate exists because a shell command typed into a
still-running picker was once consumed as keystrokes and parked nine workspaces
in one go. Single-row Enter needs no confirm: it affects one workspace and the
same key undoes it.
Verifying focus from a script is misleading: the `*` in `cmux list-windows`
marks the window owning the *calling CLI*, not the focused one. Check the target
window's `selected_workspace` instead.
Two curses details worth keeping:
- Call `locale.setlocale(locale.LC_ALL, "")` before `curses.wrapper`, or every
non-ASCII glyph renders as garbage.
- Draw with `addstr` on a **character**-truncated string, never `addnstr` with a
column count — its limit is in bytes. The progress bar uses ASCII (`=`/`-`)
for the same reason: the block/dot pair turned into U+FFFD past a certain
byte length even with the locale set.
## Performance
A scan is ~1.4 s across 45 workspaces, down from ~25 s. Keep it that way:
- **Everything per-workspace runs through `pmap`.** `cmux list-status` is a
~256 ms socket round-trip; 45 of them sequentially was 11.5 s. The status and
git fan-outs are then overlapped with each other.
- **`git_info` is one `git status --porcelain -b` call.** The five `rev-parse`
calls it used to make cost 4.4 s. It returns only `branch` and `dirty_files`,
which is everything any caller reads — an earlier `full=True` branch spent two
more subprocesses per park on fields nothing ever looked at.
- **Filter before the expensive lookups.** `collect()` decides which rows are
even displayable from `ps` data alone, then queries only those.
- **A `Spinner` covers the wait** on stderr, so it never redirects into output
and stays silent when piped.
- **A batch parks in waves of `PARK_WAVE` (8), not one at a time.** Everything a
park does is waiting — cmux round-trips, a CPU window, git — and the picker has
always parked rows concurrently; only the CLI walked its targets in single
file. `park park window:4 --dry-run` was 17.8 s for 20 targets; it now does 31
in 11.4 s. Each wave takes its own snapshot, and `attribute()` overlaps with
the 2 s CPU window that would otherwise be pure sleeping.
- **The CPU window runs underneath everything else.** It is two seconds of
sleeping, so it starts before the workspace listing and is only collected at
the gate — after attribution, the draft read, the status round-trip and the
session lookup have all happened alongside it. It runs on a plain daemon
thread rather than a `ThreadPoolExecutor`, whose workers are joined at
interpreter exit: a park that never consults the window (already parked, no
claude session, any early refusal) used to sit for the full two seconds
before the process could end. That path is now 1.3 s instead of 2.4 s.
- **The tail is one fan-out.** The prefill, the pill, the colour, the pinned
name and the after-fingerprint are five independent ~250 ms round-trips, and
sequentially they were the biggest slice of a single park after the CPU
window; the same goes for the two reads in the pre-kill re-check. A real
single park measured 4.3 s before, 3.75 s after, and what is left is
irreducible: ~2 s of CPU window (overlapped) and ~1.2 s waiting for the
session tree to actually die after SIGTERM. The prefill in particular cannot
move earlier — it has to land on a shell prompt, which only exists once
claude is gone.
- **A wave shares one CPU window.** `cpu_snapshots()` is system-wide and
corroborates eight workspaces as well as one. Per *wave* rather than per
batch on purpose: the old single reading was taken once and never refreshed,
so late targets in a long batch were judged on minutes-old numbers. Nothing
else is traded away — that gate is the corroborating one, and each workspace
still gets its own fresh pill, draft and transcript verdict re-run
immediately before the kill.
- **The kill wait is polled, not slept.** `wait_gone` returns as soon as the
tree is gone — normally well under 200 ms — and only spends its full budget
on something genuinely stuck. Measured across three targets, the two changes
together took a dry run from 10.1 s to 5.7 s.
- **`unpark` batches too, in waves of 6** (smaller: each resume ends by waiting
for its claude to appear, and a wave of them booting at once is real load).
A single target keeps the old path — that is the one that can `exec`, and it
is what `park unpark .` runs at a parked prompt. Two live workspaces resumed
and verified in 6.2 s.
The one collision a per-workspace lock cannot see is **two ledger entries
naming the same session** — a bad re-key or a hand-edited ledger does it — so
the batch claims session ids in the parent, in target order, and dispatches
only the entries that got their claim. Results are printed in target order,
never in finishing order.
## Tests
`python3 test_park.py` — 125 tests, stdlib `unittest`, ~1.4 s, no cmux required.
It covers the decisions made *before* anything is killed: who counts as a dev
server, which claude processes are root sessions, whether a turn is in flight,
how a session comes back, what the ledger still owes after a partial unpark, and
the screen reads that stand between park and something the user typed. Liveness
and process identity are tested against real processes, because the zombie case
— a corpse that still answers `kill(pid, 0)` — cannot be faked and is what used
to trigger the rollback that deleted the ledger.
The suite is checked by reintroducing each fixed bug and confirming it fails:
zombie-as-survivor, unconditional `ctrl+u`, the draft guard failing open, the
lowercase-only session-id scan, subagents counted as root sessions, binary names
matched anywhere in a command line, the pill trusted without corroboration,
wrappers hiding `git` from `NEVER_KILL`, a recycled pid matched on its command
line, deleting the ledger with tabs still owed, the prefill-only prompt check,
a non-exclusive operation lock, and `rebuild` duplicating what `rekey` could
place. Every one was caught. Add tests the same way — a test that still passes
with the bug back in does not test what it claims.
## Why this is safe
A Claude session lives entirely on disk in
`~/.claude/projects/<encoded-cwd>/<session-id>.jsonl`. The RSS is a re-derivable
runtime cache. Parking records the two coordinates needed to rebuild it — **cwd
and session id** — then kills the process. Unparking runs
`claude --resume <session-id>` in that cwd and the conversation continues with
full history.
The **ledger lives outside cmux**, at `~/.claude/parked/<workspace-uuid>.json`.
That is the whole design point: cmux's built-in hibernation stores resume state
inside cmux, so a corrupted cmux state loses the process *and* the pointer to
it. Here, even if cmux loses every workspace, `park rebuild` reconstructs them
from the ledger.
Writes are atomic (`tmp` + `os.replace`), and park **claims** the ledger file
with `O_EXCL` rather than checking `exists()` first — the check and the write
straddle a 2 s CPU sample, so two concurrent parks both passed it and both
pre-filled the prompt. A file that will not parse is surfaced by `doctor` rather
than skipped: dropping it silently made `doctor` report "no parked workspaces"
while `park` still answered "already parked".
**Git is never touched.** Park only records the branch and dirty-file count;
it never checks out, stashes, prunes or removes a worktree. A worktree cannot go
missing because nothing ever deletes one. That is not left to trust:
`worktree_fingerprint()` snapshots the checkout before the kill and re-checks it
after, and any difference is reported as `WORKTREE CHANGED during park`. That
window is only ~1.5 s though, while the real risk plays out over the days a
workspace sits parked — so `doctor` re-runs the fingerprint and diffs it against
the one in the ledger.
**What it will and will not kill.** `kill_tree` takes a whole descendant tree,
so a loose match is expensive. Matching names by substring flagged
`vim vite.config.ts` (word boundary), `rg webpack src/` (the name as an
*argument*), `/node_modules/vite/…/esbuild` (a *path segment*) and — worst —
`vim /tmp/claude`, which the old `(^|/)claude(\s|$)` classified as a **root
claude session**: it was handed the workspace's real cmux binding, recorded as
the session to resume, and killed with its descendants while park reported the
workspace parked.
So a bare binary name counts only when it is the program actually being run
(`runs_binary`: the command token, or what a launcher like `node`/`npx`/
`python -m` goes on to run, on the basename, with one script extension dropped
and value-taking launcher flags skipped so `npx --package vite prettier` is
prettier). `is_claude` uses the same matcher — applying it to dev servers but
not to claude left the loosest matcher where over-matching costs the most. On
top of that, `NEVER_KILL` unconditionally spares editors, pagers, `git` and
search tools, so `git commit -m "npm run dev broke"` can never cost a buffer.
Under-matching only leaves a dev server running, and costs little in practice —
children like `esbuild` still die with their parent through the tree.
**A process that survives is not a successful park.** `park` escalates TERM to
KILL, and if anything still lives it **removes the ledger entry it just claimed**
and reports failure. Recording a park whose session is still alive is worse than
not parking: `doctor` would call it restorable and `unpark` would start a second
process on that transcript. The same rollback runs if the claim–kill window is
interrupted.
Pids are re-validated immediately before the kill. The table is sampled once per
`park` invocation and reused for every target, so by the last workspace it can be
half a minute old; `kill_tree` takes out a whole descendant tree, and macOS
reuses pids.
## Several agent tabs in one workspace
**The workspace is the unit, not the tab.** All agent tabs in it freeze together
and thaw together; there is no per-tab freeze. Each tab is still tracked
individually — `attribute()` reads a distinct `CMUX_SURFACE_ID` per tab, so the
ledger holds one `sessions[]` entry per tab with its own session id, surface and
flags, and unpark resumes each into its own surface. Verified: two tabs seeded
with different tokens came back on the correct surfaces, contexts not crossed.
Consequences worth knowing:
- `park unpark .` is pre-filled at **every** agent tab's prompt, not just the
first, so it does not matter which tab you land on. Enter in any of them
restores the whole workspace.
- The `claude_code` status pill is per **workspace**, not per tab, so the busy
gate is workspace-wide: one tab mid-turn protects the whole workspace from
being parked. That is the safe direction, but it does mean a busy tab blocks
parking its idle neighbours.
## Coming back: the prefilled prompt
After parking, `park unpark .` is left **typed but not executed** at the
workspace's prompt, so resuming is walking into the workspace and pressing
enter. Enter runs the real unpark path — clearing the pill, the colour and the
ledger — rather than a bare resume line.
Run there, `unpark` detects it is inside the tab it is resuming and `exec`s the
resume command instead of sending keystrokes to its own surface, which would
race the shell. The match is on `CMUX_SURFACE_ID` against the session's
recorded surface — but a **rebuilt** workspace has no recorded surfaces, and
matching on the surface alone meant the resume was typed into the very tab
running park. With no surface to compare, `CMUX_WORKSPACE_ID` against the
workspace is the same statement, so that is the fallback.
**The line has to be empty before anything is typed on it.** `prepare_prompt`
clears park's own prefill, **re-reads**, and requires what remains to be a bare
prompt. Asking only "is this our prefill?" was not enough: a cmux restart wipes
the terminal buffer, so after one the prefill is gone — and if the user had
`git status` half typed, unpark left the line alone, called that success, and
appended its command plus enter. One mangled command, no session, and the ledger
entry deleted on the way out. `bare_prompt` is the other half: `❯ » ➜ ›` pass on
sight, but `$ % #` need a path- or host-looking token in front of them, or
`echo $` reads as an empty prompt. Refusing a real prompt costs a re-typed
prefill; accepting a written-on one costs the user's line.
Traps in reconstructing that command:
- **Run it through `CMUX_CLAUDE_WRAPPER_SHIM`** when present. Bypassing the shim
works, but cmux stops maintaining the workspace's agent binding and status
pill for the new session. Apply it in **one** place (`resume_command_for`):
when only the `exec` path applied it, every tab resumed by `cmux send` — the
common case — lost the binding. The shim path itself ends in `/claude`, so a
second application would silently re-match it.
- **`cmux send` is not a shell.** It interprets exactly `\n`, `\r` and `\t` and
passes every other backslash through untouched — there is no escape for a
literal backslash, so payloads go verbatim. Backslash-doubling "to be safe"
corrupts cmux's recorded commands (which contain backslash-quote runs) into a
path the shell cannot find. Enter is a real newline **byte on the same call**,
not a second `send-key`: splitting the two raced the shell, and against a slow
prompt the text landed while the enter did not — a correct resume command
typed but never run, reported as "resumed".
- **Mutating cmux calls must not retry.** A nonzero exit after the write already
landed is itself a transient-socket symptom; retrying `send` twice more leaves
`park unpark .park unpark .park unpark .` typed at the prompt. `cmux_do` is
the no-retry, no-raise path, and it returns success so callers stop reporting
actions that did not happen.
- **Never rebuild the full command from `ps`.** `ps` joins argv with spaces, so
the `--settings {json}` cmux passes cannot be split back correctly, and
replaying a mangled one makes claude refuse to start (`Invalid JSON provided
to --settings`). Only a small whitelist of flags is carried across
(`ARGV_FLAGS_BOOL` / `ARGV_FLAGS_VALUE`); the shim re-supplies the rest.
Dropping `--dangerously-skip-permissions` here is very visible — the resumed
session quietly starts asking for permissions again.
- **Quote the flag VALUES, not just the cwd.** Those tokens come from `ps`, and
the result is handed to `/bin/sh -c` (and typed into a pane). Any process can
write anything into its own argv — including `CMUX_WORKSPACE_ID=<ws>`, which
is how it gets attributed to a workspace in the first place — so an unquoted
`--model ;curl…|sh` would execute on the next unpark, disguised as a resume
and through a path the skill's `allowed-tools` pre-approves.
- **The session id needs the same treatment.** Two of its three sources are
regex-constrained, but the last-resort scan returns a raw *filename stem*, so
a file called `x; touch /tmp/PWNED #.jsonl` in the project dir became a
command. Ids are validated against `SESSION_ID_RE` before entering the ledger
and quoted at both construction sites.
- **Rewrite only the token in command position.** `with_shim` matched the first
`claude` anywhere, so for a checkout at `~/projects/claude` it rewrote the
`cd` target; the `cd` then failed, `&&` short-circuited, and unpark reported
"resumed" for a session that never started.
The ledger is written 0600 on **both** paths and its directory 0700: an entry
holds the cwd, the branch and the user's last prompt, and the non-claim write
was inheriting 0644 from the umask. `ledger_path` rejects any id that is not
`[A-Za-z0-9._-]+` — `rebuild`, `forget` and `unpark` take that id from a ledger
file's own contents, and `LEDGER / f"{ws_id}.json"` resolves `../settings` or an
absolute path happily, at which point `unlink()` deletes something else.
## Rules
- **Never park a session with unsent text in the prompt.** A draft lives only
in the agent's input box — not in the transcript, not in the process table,
nowhere on disk — so killing the session is the one operation that destroys it
silently. `unsent_input` reads it off the screen (the `>` line fenced by box
rules; requiring the rule above it is what stops a shell prompt reading as a
draft) and park refuses, quoting the text back. It runs **twice** — once
before the expensive checks and again immediately before the kill, because
Ver en GitHub