Status: Draft Author: Allen Eubank Date: 2026-01-30
Long-running processes spawned by Claude Code sessions (dev servers, Tilt,
watchers) routinely become orphaned — detached from any terminal (TTY: ??),
re-parented to launchd (PPID 1), invisible unless you manually hunt with
lsof/ps/pstree. This happens when:
- A Claude Code session ends or is compacted
- The Bash tool times out on a blocking command
- A tool like Tilt forks child processes that outlive their parent
These orphans hold ports, consume resources, and require manual forensics to find and kill.
Every long-running process Claude Code spawns should be visible and recoverable via tmux while remaining transparent to Claude Code's existing Bash tool interface — stdout, stderr, exit codes, and timeouts all work unchanged.
We have a tmux-processes skill that teaches Claude best practices for tmux
session management (naming conventions, send-keys patterns, capture-pane
monitoring, idempotent starts). This is advisory only — Claude can and does
ignore it, running commands directly via the Bash tool.
┌─────────────────────────────────────────────────────┐
│ Claude Code │
│ │
│ Bash tool call │
│ ┌───────────────────────────────────────────────┐ │
│ │ command: "npm run dev --port 5174" │ │
│ │ run_in_background: false │ │
│ └──────────────┬────────────────────────────────┘ │
│ │ │
│ ┌──────────────▼────────────────────────────────┐ │
│ │ PreToolUse Hook (Bash matcher) │ │
│ │ │ │
│ │ 1. Read tool_input.command │ │
│ │ 2. Match against long-running patterns │ │
│ │ 3. If match: rewrite command to use wrapper │ │
│ │ 4. Return updatedInput + permissionDecision │ │
│ └──────────────┬────────────────────────────────┘ │
│ │ │
│ ┌──────────────▼────────────────────────────────┐ │
│ │ Bash executes (rewritten command) │ │
│ │ │ │
│ │ claude-tmux-wrap --name dev-5174 \ │ │
│ │ -- npm run dev --port 5174 │ │
│ └──────────────┬────────────────────────────────┘ │
│ │ │
└─────────────────┼───────────────────────────────────┘
│
┌──────────────▼──────────────────────────────────┐
│ claude-tmux-wrap (wrapper binary/script) │
│ │
│ 1. Generate session name from project context │
│ 2. Create tmux session + window │
│ 3. Start command inside tmux pane │
│ 4. Proxy stdout/stderr via FIFOs → own stdout │
│ 5. Block until inner command exits │
│ 6. Return inner command's real exit code │
│ │
│ If wrapper is killed (session dies, timeout): │
│ → tmux session survives │
│ → user can: tmux attach -t <session> │
└─────────────────────────────────────────────────┘
A standalone script (bash or Go) that:
- Accepts a command and optional session/window name
- Creates a tmux session (or adds a window to an existing project session)
- Runs the command inside the tmux pane
- Proxies stdout/stderr back to the wrapper's own file descriptors via FIFOs
- Blocks until the inner command exits, then exits with its real exit code
- On abnormal termination (SIGTERM, SIGKILL), the tmux session persists
Session naming follows the existing convention: derived from git rev-parse --show-toplevel or $PWD.
A hook script registered in Claude Code settings that:
- Fires on every Bash tool invocation
- Pattern-matches the command against a configurable list of long-running
indicators (e.g.,
tilt up,npm run dev,vite,next dev,yarn .* dev,docker compose up, process on known ports) - If matched: rewrites
tool_input.commandtoclaude-tmux-wrap --name <derived> -- <original command> - If not matched: passes through unchanged (exit 0, no JSON output)
Already implemented. Teaches Claude how to:
tmux capture-panefor log inspectiontmux list-windowsfor process inventorytmux send-keys C-cfor stopping processestmux attachguidance for human takeover
No changes needed. This skill covers the "read" side; the hook + wrapper add the "write" side.
| Concern | Behavior with wrapper | Notes |
|---|---|---|
| stdout | Proxied via FIFO to wrapper stdout → Claude sees it | Use script -q or stdbuf -oL for line buffering |
| stderr | Proxied via separate FIFO → Claude sees it | Merged or separate, configurable |
| Exit code | Inner command's code written to temp file, wrapper returns it | Fully transparent |
| Bash timeout | Wrapper killed, tmux survives | Desired: process recoverable |
| Session end | Wrapper killed, tmux survives | Desired: process recoverable |
| stdin | Not proxied (command reads from tmux PTY) | Acceptable: Claude rarely pipes stdin |
| TTY dimensions | Command sees tmux PTY, not Claude's | Minor: may affect column-dependent output |
| Signal forwarding | Wrapper traps SIGINT/SIGTERM, forwards to tmux pane | Ctrl-C behavior preserved |
run_in_background |
Same wrapping; Claude polls via capture-pane per skill |
Replaces TaskOutput polling |
The hook uses an allowlist of patterns for commands that should be wrapped. Conservative by default — only wrap known long-running patterns:
# Long-running server/watcher patterns
tilt up|tilt ci
npm run (dev|start|serve|watch)
yarn (dev|start|serve|watch)
npx (vite|next|remix|astro) dev
docker compose up
python.* (manage\.py runserver|uvicorn|gunicorn|flask run)
go run .* (serve|server)
Commands NOT matched pass through to Bash unchanged. False negatives (missed patterns) are safe — the process just runs without tmux as it does today. False positives (unnecessary wrapping) add minor overhead but don't break behavior.
The pattern list is configurable — stored in a file alongside the hook, not hard-coded.
Processes from the same project share a tmux session with separate windows:
tmux session: "canton-monorepo"
├── window "dev-5174" → vite dev server
├── window "tilt" → tilt up
└── window "test-watch" → vitest --watch
This matches the convention already documented in the tmux-processes skill.
| Approach | Why not (as primary) |
|---|---|
| MCP server with custom tools | Not transparent — Claude must choose the MCP tool over Bash. Requires denying Bash access (too aggressive) or relying on Claude preference (unreliable). |
| Overmind (Procfile manager) | Only handles predefined process sets. Ad-hoc commands from Claude don't have Procfiles. Good complement, not a replacement. |
| PostToolUse audit hook | Detects orphans after the fact, doesn't prevent them. Educational but not a solution. |
| Advisory skill only | Current state. Claude ignores it when it wants to. |
run_in_background only |
No persistence across sessions. Cleaned up when Claude exits. No tmux recovery. |
-
Wrapper language — Bash script is simplest. Go binary is more robust (FIFO management, signal handling, cross-platform). Worth the build step?
-
Output fidelity — FIFOs lose PTY features (colors, cursor control). Use
script -q /dev/nullto preserve PTY semantics? Or accept clean text output as a feature? -
Pattern list ownership — Should the hook hard-code patterns, read from a config file, or derive from the project (e.g., parse
package.jsonscripts)? -
Cleanup policy — When should tmux sessions be cleaned up? Manual only? After N hours idle? On next Claude session start?
-
Multiple Claude sessions — Two Claude sessions in the same project could collide on tmux session names. Namespace by session ID, or share the session and let windows accumulate?
-
Interaction with Tilt — Tilt already manages its own process tree. Should
tilt upbe wrapped (making Tilt visible in tmux) or excluded (since Tilt has its own UI)?
- FIFO lifecycle — If the wrapper crashes without cleaning up FIFOs, stale
named pipes accumulate in
/tmp. Mitigated by usingmktempand trap-based cleanup. - Hook performance — PreToolUse hook runs on every Bash invocation. Must be fast (< 100ms). Pattern matching is cheap; the risk is in the hook startup time if using a heavy runtime.
- Double wrapping — If Claude explicitly uses tmux (following the skill) AND the hook wraps it again, you get nested tmux. Hook must detect and skip commands that already reference tmux.
- Write
claude-tmux-wrapscript with FIFO proxying + exit code forwarding - Write PreToolUse hook with pattern matching and command rewriting
- Add hook to Claude Code settings (
~/.claude/settings.json) - Test: foreground commands (blocking, stdout, exit codes)
- Test: background commands (
run_in_background: true) - Test: timeout/kill behavior (wrapper dies, tmux survives)
- Test: double-wrap detection (skip if command already uses tmux)
- Add pattern config file, document how to extend
- Update
tmux-processesskill to reference the new automatic wrapping