feat(claude-tmux): spawn detached tmux Claude Code session with Remote Control

New script that launches Claude Code (via claude-profile) inside a detached
tmux session with --remote-control enabled, in a project's directory, named
after the project (Title-cased, auto-incrementing #N on collision). Lets a
session be started for later attach over VPN or control from the Claude app.

Spec-first per repo conventions: specs/claude-tmux.spec.md, script, and
tests/test-claude-tmux.sh (18 dryrun assertions, all passing).

Claude-Session: https://claude.ai/code/session_014oa7T7z1h5n34vu8vX9zGT
This commit is contained in:
Paul O'Reilly
2026-07-25 16:29:46 +12:00
parent 0476c00e81
commit 448091ecf4
4 changed files with 375 additions and 0 deletions

View File

@@ -16,6 +16,7 @@ This project uses **spec-driven development** (OpenSpec) as a testbed for agent-
|--------|---------|--------|
| `check-skills` | Verify Claude Code skill symlinks match source repo | Done |
| `claude-profile` | Claude Code profile + engagement-mode launcher | Done |
| `claude-tmux` | Spawn a detached tmux Claude Code session with Remote Control, named after a project (wraps `claude-profile`) | Done |
| `gen-secret` | Generate bash/YAML/JSON-safe random strings | Done |
| `git-status-report` | Recursive git repo status with diff stats | Done |
| `md-to-docx` | Markdown to DOCX conversion | Done |

156
scripts/claude-tmux Executable file
View File

@@ -0,0 +1,156 @@
#!/usr/bin/env bash
# claude-tmux — Spawn a detached tmux session running Claude Code with Remote Control
#
# Usage:
# claude-tmux <project> # spawn a Remote-Control session for a project
# claude-tmux # prompt for project (TTY only)
# claude-tmux <project> --mode <mode> # override engagement mode
# claude-tmux <project> --profile <name> # override profile (default: oreillyit-anthropic)
# claude-tmux <project> --name <display> # override session / Remote-Control name
# claude-tmux <project> --attach # attach (or switch-client) after spawning
# claude-tmux <project> --dryrun # preview, create nothing
#
# Spec: specs/claude-tmux.spec.md
set -euo pipefail
# === Constants ===
DEFAULT_PROFILE="oreillyit-anthropic"
CLAUDE_ROOT="$HOME/dev/claude"
PROFILE_DIR_PREFIX="$HOME/.claude-"
SCRIPT_PATH="$(readlink -f "$0")"
SCRIPT_DIR="$(dirname "$SCRIPT_PATH")"
# === Colours ===
RED='\033[0;31m'; GREEN='\033[0;32m'; NC='\033[0m'
err() { echo -e "${RED}Error:${NC} $*" >&2; exit 1; }
# === CLI parsing ===
PROJECT=""
MODE=""
PROFILE="$DEFAULT_PROFILE"
NAME_OVERRIDE=""
DRYRUN=0
ATTACH=0
usage() { sed -n '2,13p' "$SCRIPT_PATH" | sed 's/^# \?//'; }
while [[ $# -gt 0 ]]; do
case "$1" in
--mode) MODE="${2:-}"; shift 2 ;;
--profile) PROFILE="${2:-}"; shift 2 ;;
--name) NAME_OVERRIDE="${2:-}"; shift 2 ;;
--attach) ATTACH=1; shift ;;
--dryrun|-n) DRYRUN=1; shift ;;
--help|-h) usage; exit 0 ;;
-*) err "Unknown flag: $1" ;;
*)
if [[ -z "$PROJECT" ]]; then PROJECT="$1"; else err "Unexpected argument: $1"; fi
shift ;;
esac
done
# === Resolve profile dir ===
if [[ "$PROFILE" == "default" ]]; then
PROFILE_DIR="$HOME/.claude"
else
PROFILE_DIR="${PROFILE_DIR_PREFIX}${PROFILE}"
fi
[[ -d "$PROFILE_DIR" ]] || err "Profile directory not found: $PROFILE_DIR"
# === Resolve project (prompt if TTY, else error) ===
if [[ -z "$PROJECT" ]]; then
if [[ -t 0 ]]; then
read -rp "Project name: " PROJECT
fi
[[ -n "$PROJECT" ]] || err "No project given. Pass a project name as the first argument."
fi
resolve_project_dir() {
local p="$1"
if [[ -d "$p" ]]; then (cd "$p" && pwd); return 0; fi
if [[ -d "$CLAUDE_ROOT/$p" ]]; then echo "$CLAUDE_ROOT/$p"; return 0; fi
if [[ -d "$CLAUDE_ROOT/projects/$p" ]]; then echo "$CLAUDE_ROOT/projects/$p"; return 0; fi
return 1
}
PROJECT_DIR=$(resolve_project_dir "$PROJECT") \
|| err "Project folder not found for '$PROJECT' (looked in $CLAUDE_ROOT/ and $CLAUDE_ROOT/projects/)"
PROJECT_BASENAME="$(basename "$PROJECT_DIR")"
# === Resolve mode (always explicit — never let claude-profile show its picker) ===
if [[ -z "$MODE" ]]; then
[[ -f "$PROFILE_DIR/last-mode" ]] && MODE="$(cat "$PROFILE_DIR/last-mode")"
MODE="${MODE:-quick}"
fi
# === Locate claude-profile ===
if command -v claude-profile >/dev/null 2>&1; then
CLAUDE_PROFILE_BIN="$(command -v claude-profile)"
else
CLAUDE_PROFILE_BIN="$SCRIPT_DIR/claude-profile"
fi
[[ -x "$CLAUDE_PROFILE_BIN" ]] \
|| err "claude-profile not found (looked on PATH and at $SCRIPT_DIR/claude-profile)"
# === Derive display name (Title Case) with auto-increment ===
title_case() {
echo "$1" | tr '_-' ' ' \
| awk '{ for (i=1; i<=NF; i++) $i = toupper(substr($i,1,1)) substr($i,2); print }'
}
session_exists() { tmux has-session -t "=$1" 2>/dev/null; }
if [[ -n "$NAME_OVERRIDE" ]]; then
BASE_NAME="$NAME_OVERRIDE"
else
BASE_NAME="$(title_case "$PROJECT_BASENAME")"
fi
NAME="$BASE_NAME"
n=2
while session_exists "$NAME"; do
NAME="$BASE_NAME #$n"
n=$((n + 1))
done
# === Build launch command (shell-safe) ===
LAUNCH_CMD=$(printf '%q %q --mode %q --project %q -- --remote-control %q' \
"$CLAUDE_PROFILE_BIN" "$PROFILE" "$MODE" "$PROJECT_BASENAME" "$NAME")
# Prefer the short PATH name in display/execution when it resolved via PATH.
if [[ "$CLAUDE_PROFILE_BIN" == "$(command -v claude-profile 2>/dev/null)" ]]; then
LAUNCH_CMD=$(printf 'claude-profile %q --mode %q --project %q -- --remote-control %q' \
"$PROFILE" "$MODE" "$PROJECT_BASENAME" "$NAME")
fi
# === Dryrun ===
if (( DRYRUN )); then
echo "[dryrun] Project: $PROJECT_BASENAME ($PROJECT_DIR)"
echo "[dryrun] Profile: $PROFILE ($PROFILE_DIR)"
echo "[dryrun] Mode: $MODE"
echo "[dryrun] Session name: $NAME"
echo "[dryrun] Would run: tmux new-session -d -s \"$NAME\" -c \"$PROJECT_DIR\""
echo "[dryrun] Would send: $LAUNCH_CMD"
echo "[dryrun] Attach with: tmux attach -t \"$NAME\""
exit 0
fi
# === Spawn ===
tmux new-session -d -s "$NAME" -c "$PROJECT_DIR"
# send-keys pane-target does not accept the "=" exact-match prefix; a bare name
# still prefers an exact session match, so this is unambiguous.
tmux send-keys -t "$NAME" -l "$LAUNCH_CMD"
tmux send-keys -t "$NAME" Enter
echo -e "${GREEN}Spawned${NC} tmux session: $NAME"
echo " dir: $PROJECT_DIR"
echo " profile: $PROFILE / mode: $MODE"
echo " attach: tmux attach -t \"$NAME\""
echo " app: Remote Control name \"$NAME\""
if (( ATTACH )); then
if [[ -n "${TMUX:-}" ]]; then
tmux switch-client -t "=$NAME"
else
tmux attach -t "=$NAME"
fi
fi

105
specs/claude-tmux.spec.md Normal file
View File

@@ -0,0 +1,105 @@
# claude-tmux
## Purpose
Spawn a detached `tmux` session running Claude Code (via `claude-profile`) with
Remote Control enabled, in a project's directory, with the session named after
the project so it is easy to `tmux attach` to and easy to identify in the Claude
app.
## Usage
```
claude-tmux [PROJECT] [OPTIONS]
```
### Arguments
| Argument | Default | Description |
|----------|---------|-------------|
| `PROJECT` | *(prompt if TTY)* | Project name (folder under `~/dev/claude` or `~/dev/claude/projects`), or an explicit path to a directory |
### Flags
| Flag | Default | Description |
|------|---------|-------------|
| `--mode <name>` | profile's `last-mode`, else `quick` | Engagement mode passed to `claude-profile` |
| `--profile <name>` | `oreillyit-anthropic` | Claude Code profile (resolves to `~/.claude-<name>`, or `~/.claude` for `default`) |
| `--name <display>` | Title-cased project name | Override the session / Remote-Control name |
| `--attach` | off | Attach (or `switch-client` if already inside tmux) after spawning |
| `--dryrun`, `-n` | off | Preview all actions without creating anything |
| `--help`, `-h` | | Show usage information |
## Behaviour
1. Resolve the profile directory (`~/.claude-<profile>`, or `~/.claude` for
`default`). Error if it does not exist.
2. Resolve the project. If `PROJECT` is empty and stdin is a TTY, prompt for it;
if empty and not a TTY, error. Resolve the folder by trying, in order: an
existing path, `~/dev/claude/<PROJECT>`, `~/dev/claude/projects/<PROJECT>`.
Error if none exist.
3. Resolve the engagement mode. If `--mode` is not given, read the profile's
`last-mode` file, falling back to `quick`. The mode is **always** resolved
explicitly and passed to `claude-profile` — the launcher must never leave
`claude-profile` to show its interactive mode picker, because Remote Control
only registers in the app once `claude` is actually running.
4. Derive the base session name: Title-Case the project folder basename
(`agent-runtimes``Agent Runtimes`), unless `--name` overrides it.
5. Auto-increment: if a tmux session with the base name already exists, append
` #2`, ` #3`, … until a free name is found. The first session gets no suffix.
6. Create a detached tmux session named after the resolved name, with its working
directory set to the project folder.
7. Send the launch command into the session's pane (sent literally, followed by
Enter):
`claude-profile <profile> --mode <mode> --project <basename> -- --remote-control "<name>"`
8. Print the attach hint and the Remote-Control name. If `--attach` is set,
attach (or `switch-client` when already inside tmux).
`claude-profile` is located via `PATH`, falling back to a sibling of this script.
## Dryrun Behaviour
When `--dryrun` / `-n` is passed, no tmux session is created and nothing is sent.
Output (values substituted):
```
[dryrun] Project: agent-runtimes (/home/paul/dev/claude/projects/agent-runtimes)
[dryrun] Profile: oreillyit-anthropic (/home/paul/.claude-oreillyit-anthropic)
[dryrun] Mode: deep
[dryrun] Session name: Agent Runtimes
[dryrun] Would run: tmux new-session -d -s "Agent Runtimes" -c "/home/paul/dev/claude/projects/agent-runtimes"
[dryrun] Would send: claude-profile oreillyit-anthropic --mode deep --project agent-runtimes -- --remote-control Agent\ Runtimes
[dryrun] Attach with: tmux attach -t "Agent Runtimes"
```
## Edge Cases
| Case | Handling |
|------|----------|
| No PROJECT and stdin is a TTY | Prompt `Project name:` |
| No PROJECT and not a TTY (e.g. run by an agent) | Error: "No project given…", exit 1 |
| Project folder not found | Error naming the searched locations, exit 1 |
| Profile directory not found | Error, exit 1 |
| Session name already taken | Auto-increment with ` #N` suffix |
| Project name has underscores | Treated like hyphens for Title-Casing |
| `claude-profile` not found | Error, exit 1 |
| Two concurrent sessions on the same profile | Both work: `claude-profile` rewrites the shared `active-mode.env`, but each session's **cwd takes precedence** for project auto-selection, so the correct project is still picked |
## Examples
```bash
# Spawn a Remote-Control session for agent-runtimes (Title-cased, mode from last-mode)
$ claude-tmux agent-runtimes
Spawned tmux session: Agent Runtimes
attach: tmux attach -t "Agent Runtimes"
# A second one auto-increments
$ claude-tmux agent-runtimes
Spawned tmux session: Agent Runtimes #2
# Override the mode, and attach immediately
$ claude-tmux cluster-bootstrap --mode deep --attach
# Preview only
$ claude-tmux dns-manager --dryrun
```

113
tests/test-claude-tmux.sh Executable file
View File

@@ -0,0 +1,113 @@
#!/usr/bin/env bash
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
CLAUDE_TMUX="$SCRIPT_DIR/scripts/claude-tmux"
RED='\033[0;31m'; GREEN='\033[0;32m'; NC='\033[0m'
pass=0; fail=0
assert_eq() {
local desc="$1" expected="$2" actual="$3"
if [[ "$expected" == "$actual" ]]; then
echo -e "${GREEN}PASS${NC}: $desc"; ((pass++))
else
echo -e "${RED}FAIL${NC}: $desc"; echo " expected: $expected"; echo " actual: $actual"; ((fail++))
fi
}
assert_contains() {
local desc="$1" needle="$2" haystack="$3"
if [[ "$haystack" == *"$needle"* ]]; then
echo -e "${GREEN}PASS${NC}: $desc"; ((pass++))
else
echo -e "${RED}FAIL${NC}: $desc"; echo " needle: $needle"; echo " haystack: $haystack"; ((fail++))
fi
}
assert_exit() {
local desc="$1" expected="$2" actual="$3"
if [[ "$expected" -eq "$actual" ]]; then
echo -e "${GREEN}PASS${NC}: $desc"; ((pass++))
else
echo -e "${RED}FAIL${NC}: $desc"; echo " expected exit: $expected"; echo " actual exit: $actual"; ((fail++))
fi
}
# === Hermetic environment ===
TEST_HOME="$(mktemp -d)"
STUB_BIN="$(mktemp -d)"
cleanup() {
rm -rf "$TEST_HOME" "$STUB_BIN"
tmux kill-session -t "=Agent Runtimes" 2>/dev/null || true
}
trap cleanup EXIT
# Stub claude-profile on PATH so LAUNCH_CMD renders deterministically.
cat > "$STUB_BIN/claude-profile" <<'EOF'
#!/usr/bin/env bash
exit 0
EOF
chmod +x "$STUB_BIN/claude-profile"
export HOME="$TEST_HOME"
export PATH="$STUB_BIN:$PATH"
mkdir -p "$HOME/.claude-oreillyit-anthropic"
echo "deep" > "$HOME/.claude-oreillyit-anthropic/last-mode"
mkdir -p "$HOME/dev/claude/projects/agent-runtimes"
mkdir -p "$HOME/dev/claude/octopus"
echo "=== claude-tmux tests ==="
echo
# --- Basic dryrun: title-case name, mode from last-mode ---
out=$("$CLAUDE_TMUX" agent-runtimes --dryrun 2>&1)
assert_contains "dryrun shows title-cased session name" "Session name: Agent Runtimes" "$out"
assert_contains "dryrun mode from last-mode (deep)" "Mode: deep" "$out"
assert_contains "dryrun resolves projects/ folder" "Project: agent-runtimes (" "$out"
assert_contains "dryrun launch cmd has remote-control name" '--remote-control Agent\ Runtimes' "$out"
assert_contains "dryrun launch cmd passes --project basename" "--project agent-runtimes" "$out"
assert_contains "dryrun attach hint quotes the name" 'tmux attach -t "Agent Runtimes"' "$out"
# --- Mode override ---
out=$("$CLAUDE_TMUX" agent-runtimes --mode quick --dryrun 2>&1)
assert_contains "dryrun honours --mode override" "Mode: quick" "$out"
# --- Name override ---
out=$("$CLAUDE_TMUX" agent-runtimes --name "Custom Name" --dryrun 2>&1)
assert_contains "dryrun honours --name override" "Session name: Custom Name" "$out"
# --- Top-level (non-projects) folder resolves ---
out=$("$CLAUDE_TMUX" octopus --dryrun 2>&1)
assert_contains "dryrun resolves top-level ~/dev/claude folder" "Session name: Octopus" "$out"
# --- Unknown project errors ---
out=$("$CLAUDE_TMUX" no-such-project --dryrun 2>&1); rc=$?
assert_exit "unknown project exits 1" 1 "$rc"
assert_contains "unknown project error mentions search locations" "Project folder not found" "$out"
# --- No project, non-TTY errors ---
out=$("$CLAUDE_TMUX" --dryrun </dev/null 2>&1); rc=$?
assert_exit "no project (non-TTY) exits 1" 1 "$rc"
assert_contains "no project error is helpful" "No project given" "$out"
# --- Missing profile errors ---
out=$("$CLAUDE_TMUX" agent-runtimes --profile no-such-profile --dryrun 2>&1); rc=$?
assert_exit "missing profile exits 1" 1 "$rc"
assert_contains "missing profile error names dir" "Profile directory not found" "$out"
# --- Help ---
out=$("$CLAUDE_TMUX" --help 2>&1); rc=$?
assert_exit "--help exits 0" 0 "$rc"
assert_contains "--help shows script name" "claude-tmux" "$out"
# --- Auto-increment against a live session ---
if tmux new-session -d -s "Agent Runtimes" -c /tmp 2>/dev/null; then
out=$("$CLAUDE_TMUX" agent-runtimes --dryrun 2>&1)
assert_contains "auto-increments to #2 when base name taken" "Session name: Agent Runtimes #2" "$out"
tmux kill-session -t "=Agent Runtimes" 2>/dev/null || true
else
echo -e "${GREEN}SKIP${NC}: auto-increment (tmux server unavailable)"
fi
echo
echo "=== $pass passed, $fail failed ==="
[[ "$fail" -eq 0 ]]