Imported from martin-janci/claude-fleet (
skills/claude-fleet-control/SKILL.md). Install upstream withnpx skills add martin-janci/claude-fleet --skill claude-fleet-control. Copyright stays with the author.
Controlling claude-fleet
claude-fleet runs long-lived Claude Code sessions in tmux across machines (mac, mefistos, hetzner). Its MCP server exposes tools to observe and act on those sessions. The tool descriptions tell you what each does; this skill is the workflow that ties them together — the parts that aren't obvious.
The core loop: act, wait, observe
send_prompt only types text + Enter into the session's terminal. It does not
return the reply. Claude needs a few seconds to respond, and the output is the
live tmux screen, not a clean transcript. So driving a session is a loop:
send_prompt(session_id, text)— send the instruction.- Wait (~3–8s; longer for heavy work) before reading.
capture_session(session_id)— read the pane. Passscrollback_lines(e.g. 200) when you need more than the visible screen.- If the reply is incomplete or Claude is still working (streaming text, a
spinner), wait and capture again. Repeat until you see Claude's input prompt
box / a bare
>with no activity (idle) or your task is done.
Always get session_id from list_sessions first. By default it returns
slim summary rows — id, host_alias, tmux_name, project_id, worktree_id,
status, claude_status, stuck_kind, lost_at, is_controller — enough to pick a
target without blowing past MCP token caps on a big fleet. To get the rich
auto-derived fields:
peer_status(session_id)— addscurrent_activity(REPL footer line like "Sketching") andcontext_pct(percent of context window used) for one session in one call.list_sessions { summary: false }— fullSessionRowfor every match (current_activity,context_pct,pr_url,notes, …). Use sparingly.
Scope the call with optional filters (any combination): host_alias,
project_id, status, claude_status. Ghosts are hidden by default —
pass include_lost: true to surface them (required before
recreate_session can resurrect one).
Useful fields visible in the default summary:
claude_status—working/idle/stuck/ unknownstuck_kind—auth_menu/confirmation/nonelost_at— non-null = ghost (lost from tmux)is_controller— true on the calling session once you've registered (see below)
Orient with fleet_health / list_hosts / list_projects when you don't yet
know what exists.
Spawning a session — new_session
new_session { host_alias, project_id, name } starts a session in an existing
project — add worktree_id to land in a specific worktree, or omit it for the
project root. To start work on a fresh branch, pass
new_worktree: "<branch>" instead of worktree_id: it creates a new git
worktree + branch. By default the branch forks from the repo's default
branch; pass base_branch: "dev" to fork from another branch (it falls back
to the default branch if dev doesn't exist on that host). Remote hosts
auto-clone the repo if it's missing.
Plain-shell sessions — new_shell_session
new_session runs Claude Code in the pane. When you want a raw shell —
to run a dev server, watch a build, exec ad-hoc commands — use
new_shell_session { host_alias, project_id, name, worktree_id?, new_worktree?, base_branch?, start_command? } instead. Same project/worktree plumbing
(remote auto-clones, new_worktree forks a branch, etc.), but the pane is an
interactive login shell.
start_command runs once before the shell drops to an interactive prompt;
the pane stays alive after it exits, so the session survives even if the
command is cargo run and you Ctrl+C it. Use it for "leave this running":
new_shell_session { host_alias: "mefistos", project_id: 7,
name: "ppt-web-dev", start_command: "pnpm dev:ppt" }
You steer a shell session the same way you steer a Claude one:
send_prompt(session_id, "ls -la")— types the text + Enter (it's justtmux send-keys; works equally well for a REPL or a bash prompt).capture_session(session_id)— reads the pane.
The escalation ladder (stuck / recreate / restart) and the controller guard
both apply — shell sessions appear in list_sessions like any other.
Host clipboard — get_clipboard / set_clipboard
When you need to hand text to the human at the keyboard (or read something
they just copied), use the clipboard tools. Both take a host_alias and
operate on that host's system clipboard:
get_clipboard { host_alias }— returns the current clipboard contents. An empty clipboard returns(clipboard is empty)rather than an empty block.set_clipboard { host_alias, content }— overwrites the clipboard. Capped at 64 KiB.
The tools probe wl-paste/wl-copy (Wayland), then xclip, then xsel
(X11), then pbpaste/pbcopy (macOS) — the first installed helper wins.
On a headless Linux box with no display + none of those helpers installed,
the call fails with E_CLIPBOARD_UNAVAILABLE — that's a host config gap,
not a bug to retry.
Identifying yourself — register_self
Before doing any session lifecycle work, call register_self { host_alias, tmux_name } once at the top of your run. This records you as the fleet
controller in the store. Effects:
list_sessionsmarks your row withis_controller: trueso other controllers can recognise you.kill_session/restart_session/recreate_sessionwill refuse to target the controller — they returnE_SELF_TARGET. Passforce: trueif you really mean to act on yourself (e.g. a deliberate restart).broadcast_promptexcludes the controller from its fan-out by default, so you never spam yourself.
Skip this only for read-only or one-shot tasks where you'll never be a plausible target.
Discover your identity programmatically — never guess the alias.
tmux_name comes from tmux display-message -p '#S'; host_alias is
configuration (whatever the user picked in the host picker) and is not
derivable from hostname. The two diverge whenever the user renames a host,
so a hostname-based fast path silently breaks. Always look up the alias:
tmux display-message -p '#S'— yourtmux_name.mcp__claude-fleet__list_sessions {}— find the row whosetmux_namematches; take itshost_alias.mcp__claude-fleet__register_self { host_alias: "<discovered>", tmux_name: "<#S>" }.
Same pattern the fleet-friendly-name skill uses; see it for edge cases
(no matching row, multiple matches).
Recovering a misbehaving session — escalation ladder
Look before you climb. Read the row from list_sessions (or call
peer_status) and use claude_status, stuck_kind, and context_pct to
pick the right rung — don't recreate_session something that just needs a
nudge.
Climb only as far as needed; each step is more destructive:
| Symptom | Action |
|---|---|
claude_status: stuck with stuck_kind: confirmation / auth_menu |
send_prompt(session_id, "1") (or the right keystroke) to dismiss it |
claude_status: idle but waiting at a prompt |
send_prompt(session_id, "continue") |
context_pct near 100 — the conversation is full |
recreate_session resumes the same Claude id in a fresh REPL |
| REPL itself wedged, but tmux fine | restart_session — relaunches Claude in place |
| Frozen / eating RAM / needs a clean slate | recreate_session — kills + rebuilds the tmux session in the same worktree, resuming the same Claude conversation |
Lost from tmux (ghost — status: "ghost"; pass include_lost: true to see them in list_sessions) |
recreate_session to bring it back, or dismiss_ghost_session to drop it |
recreate_session is destructive (kills the running process) but preserves
the conversation via session-id resume — prefer send_prompt/restart for
in-place fixes. If the target is the registered controller, pass
force: true or the call refuses with E_SELF_TARGET.
Inspecting a session's work
Before reporting or acting, read the worktree (read-only): repo_changes
(status), repo_diff(session_id, path) (one file's diff), repo_file,
repo_tree; and git state via repo_log / repo_branches / repo_commit /
repo_commit_diff.
Session timeline — session_history
Every status change, prompt send, stuck detection, kill, recreate, and
peer message is appended to a per-session event log. Pull it with
session_history { session_id, limit? } when you need the story of
what happened — capture_session only shows the current screen.
Recorded kinds: status_change · prompt_sent · stuck · killed ·
recreated · message_sent · message_received. The response is newest-
first; default limit is 50.
Checking what a peer is doing — peer_status
You don't need to capture_session to find out whether a peer is working,
idle, or stuck. peer_status(session_id) returns the reconcile-derived
fields in one call:
claude_status—working/idle/stuck/ …current_activity— the REPL footer line ("Sketching", "Running test", …)stuck_kind—auth_menu/confirmation/nonecontext_pct— percent of context window used
Use it before send_message/send_prompt to avoid pinging a peer that's
mid-stream, and before broadcast_prompt to pick a real status filter.
For the same fields across every session, call list_sessions { summary: false } — the default summary mode omits current_activity and
context_pct to keep responses inside MCP token caps.
Peer-to-peer messaging — send_message + inbox
send_prompt types text directly into a peer's REPL, which interrupts
whatever they're doing. For coordination between sessions, prefer the
inbox:
send_message { from_session_id, to_session_id, body, kind?, deliver? }— appends a row to the recipient's inbox. Thefromid is preserved so the receiver knows who wrote, andkindis a free tag (message/task/reply/alert).- The recipient calls
inbox { session_id, unread_only?, mark_read? }when it's ready — newest-first, and unread rows are marked read by default.
Set deliver: true to also type the message into the peer's pane with a
[msg #id from name@host]: header — useful for urgent prompts where you
want both a paper trail and immediate attention. The inbox row is the
source of truth; pane delivery is best-effort and delivered_to_pane /
deliver_error in the response tell you what happened.
A typical exchange:
A → send_message(from=A, to=B, body="please review PR #42", kind="task")
B → inbox(session_id=B) # reads, marks read
B → send_message(from=B, to=A, body="LGTM", kind="reply", deliver=true)
A → inbox(session_id=A) # or sees the pane line directly
For one-to-many work use broadcast_prompt (next section); for one-to-one
coordination prefer send_message.
Broadcasting to many sessions — broadcast_prompt
When the same instruction needs to reach every matching work session,
broadcast_prompt { host?, project_id?, status?, prompt, submit? } is the
fan-out. The filter is AND-combined; omit a field to leave it open:
host— only sessions on this host aliasproject_id— only sessions in this project (fromlist_projects)status— only sessions whoseclaude_statusmatches (e.g."idle")
The call returns a per-session result vector (sent / failed counts + each target's outcome) so you can see who got it. Two implicit guardrails: only work sessions are eligible (background sessions are skipped), and the registered controller is excluded — so you never broadcast to yourself.
Typical use: broadcast_prompt { status: "idle", prompt: "git pull && pnpm test" }
to wake every idle worker. Use peer_status/list_sessions first to set a
realistic status filter — broadcasting to working sessions interrupts
them mid-stream.
Headless work
For unattended tasks use new_bg_session(host, name, prompt) and check progress
with peek_session(session_id). (peek_session on an interactive session
returns an informational message — interactive sessions have no background job;
use capture_session for those.)
Recovering from MCP errors
Three error classes, three different responses — do not blanket-retry:
-
Application errors (
E_NOTFOUND,E_SELF_TARGET,E_LOCK,E_VALIDATION, …): the call reached the server and the server said no. Surface immediately, then re-sync state before any retry. The canonical re-sync calls arelist_sessions,list_hosts,list_projects,list_worktrees— they return fresh truth from the store.E_NOTFOUNDon a session id usually means the row moved (ghost, recreated, renamed);list_sessions { include_lost: true }surfaces ghosts sorecreate_sessioncan revive them. -
Transient transport errors (timeout, connection drop, 5xx): retry once after a short wait, then re-sync state. The MCP server is local (or via reverse tunnel) and recovers quickly. If the second attempt also fails, surface — don't loop.
-
Destructive ops (
kill_session,safe_kill_session,recreate_session,restart_session,delete_worktree,remove_host,dismiss_ghost_session): never auto-retry. A timeout doesn't mean the op didn't happen — it may have succeeded server-side and the response was lost. Re-sync first (list_sessions/list_hosts/list_worktrees), confirm the actual state, then decide whether to act again.
Self-healing pattern — when a tool that takes a session_id errors with
E_NOTFOUND, your in-memory id is stale. Call list_sessions (or
related_sessions { session_id } if you have a sibling id) to find the
current row before retrying. Do not loop the same id.
Common mistakes
- Reading right after
send_prompt→ empty/partial output. Wait first, thencapture_session; loop. - Treating
capture_sessionas a transcript — it's the current screen; usescrollback_linesfor history. - Jumping to
recreate_sessionfor a session that just needed a nudge — trysend_prompt/restart_sessionfirst. - Auto-retrying a destructive op after a timeout — the op may have succeeded; re-sync state first (see "Recovering from MCP errors" above).