Skip to main content

park-workspace

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.

Datos de origen

Repositorio
Vesely/skills
Última actividad en el origen
23 de agosto de 2026 a las 17:01
Idioma detectado de SKILL.md
inglés
Estrellas
28
Forks
4

Opciones de instalación

De forma predeterminada está seleccionado el prompt que primero revisa el origen. Puedes cambiar a un comando directo o descargar una copia local.

Revisa los archivos de origen

Lee SKILL.md y los archivos complementarios que muestra SkillsMP antes de decidir si quieres instalarlo.

Explorador de archivos
3 archivos

Mostrando SKILL.md

SKILL.md
Instrucciones de origen · Vista previa de solo lectura
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
Este SKILL.md es muy grande, por eso SkillsMP muestra aqui solo la primera seccion. Ver en GitHub