Add orchestrate skill: main-actor pattern + mav-agent/mav-tmux wrappers

One bot owns the chat and delegates coding to persistent tmux sub-agents,
using a .done file marker for a reliable handoff (status/wait/result).
Includes README + install.sh for one-command setup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mav
2026-05-29 11:31:15 +02:00
commit f509792872
5 changed files with 348 additions and 0 deletions

53
README.md Normal file
View File

@@ -0,0 +1,53 @@
# orchestrate-skill
The **main-actor** pattern for bot teammates. One bot owns the chat and never
blocks — it delegates real coding/build work to persistent `tmux` sub-agents,
polls a done-marker, and reports results back.
> One voice talks, hands stay clean.
## What's inside
```
skill/SKILL.md the skill itself (the playbook, conventions, anti-patterns)
bin/mav-agent spawn/observe named claude or codex sub-agents (one tmux window each)
bin/mav-tmux run a one-off command in the shared persistent tmux session
install.sh drop the skill + wrappers into your ~/.claude and ~/.local/bin
```
## Why
If you do heavy work inline in your own turn, the chat freezes and the work dies
when your session compacts or restarts. Instead: spawn the work in a tmux window
that **outlives your turn**, and use a real file marker for the handoff so you
always know when it's finished — no prompt-sniffing, no guessing.
## The clean handoff
Every `mav-agent run` writes, when the CLI exits:
- `/tmp/mav-agent/<name>.out` — full stdout/stderr
- `/tmp/mav-agent/<name>.done` — exit code (present **only** when finished)
`mav-agent status` / `wait` poll the marker. Present ⇒ done. That's the whole trick.
## Install
```bash
git clone https://git.sevara.cloud/mav/orchestrate-skill.git
cd orchestrate-skill && ./install.sh
```
Requires `tmux`, plus `claude` and/or `codex` on PATH. Then the `/orchestrate`
skill shows up in your Claude Code session and the `mav-agent`/`mav-tmux` CLIs are
on your PATH.
## The loop
1. Acknowledge in chat fast — never a silent turn.
2. `mav-agent run <name> "<self-contained task with full context>"`
3. `mav-agent wait <name> 60` (short) or end turn + `mav-agent status <name>` next wake.
4. On done: `mav-agent result <name>`, summarize, reply. Check the exit code.
5. `mav-agent kill <name>` once you've read it.
See `skill/SKILL.md` for the full reference.

126
bin/mav-agent Executable file
View File

@@ -0,0 +1,126 @@
#!/usr/bin/env bash
# mav-agent — spawn a persistent sub-agent (claude or codex) in a named tmux window.
# each agent lives in its own tmux window inside the "mav" session.
#
# usage:
# mav-agent run <name> <task> spawn a claude sub-agent with task
# mav-agent run <name> --codex <task> spawn a codex sub-agent with task
# mav-agent read <name> capture current output from agent window
# mav-agent status <name> running | done | none (reliable, marker-based)
# mav-agent wait <name> [timeout_s] block until agent done (marker-based)
# mav-agent result <name> print the agent's final stdout (after done)
# mav-agent list list all agent windows + status
# mav-agent kill <name> kill an agent window
#
# agents run in mav tmux session, window named <name>.
# clean handoff: each run writes /tmp/mav-agent-<name>.done when the CLI exits,
# and streams full stdout to /tmp/mav-agent-<name>.out. status/wait poll the marker
# (no flaky prompt-sniffing), so the main actor always knows when work is finished.
SESSION="mav"
RUNDIR="/tmp/mav-agent"
mkdir -p "$RUNDIR" 2>/dev/null
CLAUDE=/Users/m1/.local/bin/claude
OR_KEY=$(python3 -c "import json; print(json.load(open('/Users/m1/.config/mav/bridge/config.json')).get('openrouterKey',''))" 2>/dev/null)
# ensure session exists
TMUX= tmux has-session -t "$SESSION" 2>/dev/null || TMUX= tmux new-session -d -s "$SESSION"
window_exists() { TMUX= tmux list-windows -t "$SESSION" -F "#{window_name}" 2>/dev/null | grep -q "^${1}$"; }
case "${1:-list}" in
run)
NAME="${2:?usage: mav-agent run <name> [--codex] <task>}"
shift 2
# detect --codex flag
CLI="claude"
if [[ "${1:-}" == "--codex" ]]; then CLI="codex"; shift; fi
TASK="${*:?task required}"
# kill existing window with same name if present
window_exists "$NAME" && TMUX= tmux kill-window -t "${SESSION}:${NAME}" 2>/dev/null
OUT="$RUNDIR/${NAME}.out"
DONE="$RUNDIR/${NAME}.done"
rm -f "$OUT" "$DONE" 2>/dev/null
# create new window at the next free index (avoids "index N in use" on gaps)
NEXT=$(( $(TMUX= tmux list-windows -t "$SESSION" -F "#{window_index}" 2>/dev/null | sort -n | tail -1) + 1 ))
TMUX= tmux new-window -d -t "${SESSION}:${NEXT}" -n "$NAME"
if [[ "$CLI" == "codex" ]]; then
CLI_CMD="codex --approval-mode auto-edit -q $(printf '%q' "$TASK")"
else
CLI_CMD="$CLAUDE -p $(printf '%q' "$TASK") --output-format text --permission-mode bypassPermissions --model opus"
fi
# stream stdout to a file, then drop a done-marker with the exit code.
# this is the clean handoff: marker => work finished, no prompt-sniffing.
# run via bash -c so PIPESTATUS works regardless of the pane's login shell.
INNER="{ $CLI_CMD ; } 2>&1 | tee $(printf '%q' "$OUT"); echo \${PIPESTATUS[0]} > $(printf '%q' "$DONE")"
CMD="bash -c $(printf '%q' "$INNER")"
TMUX= tmux send-keys -t "${SESSION}:${NAME}" "$CMD" Enter
echo "[mav-agent] started: $CLI agent in window '${SESSION}:${NAME}'"
echo "task: $TASK"
echo "marker: $DONE | output: $OUT"
;;
status)
NAME="${2:?usage: mav-agent status <name>}"
if [[ -f "$RUNDIR/${NAME}.done" ]]; then echo "done (exit $(cat "$RUNDIR/${NAME}.done" 2>/dev/null | tr -d '[:space:]'))";
elif window_exists "$NAME"; then echo "running";
else echo "none"; fi
;;
result)
NAME="${2:?usage: mav-agent result <name>}"
if [[ -f "$RUNDIR/${NAME}.out" ]]; then cat "$RUNDIR/${NAME}.out"; else echo "no output for: $NAME"; exit 1; fi
;;
read)
NAME="${2:?usage: mav-agent read <name>}"
if ! window_exists "$NAME"; then echo "no agent window: $NAME"; exit 1; fi
TMUX= tmux capture-pane -t "${SESSION}:${NAME}" -p 2>/dev/null
;;
wait)
NAME="${2:?usage: mav-agent wait <name> [timeout_s]}"
TIMEOUT="${3:-120}"
DONE="$RUNDIR/${NAME}.done"
if [[ ! -f "$DONE" ]] && ! window_exists "$NAME"; then echo "no agent: $NAME"; exit 1; fi
echo "waiting for $NAME to finish (${TIMEOUT}s max)..."
for i in $(seq 1 $((TIMEOUT * 2))); do
if [[ -f "$DONE" ]]; then
echo "[done] exit $(cat "$DONE" 2>/dev/null | tr -d '[:space:]')"
tail -20 "$RUNDIR/${NAME}.out" 2>/dev/null
exit 0
fi
sleep 0.5
done
echo "[timeout] agent still running after ${TIMEOUT}s"
tail -10 "$RUNDIR/${NAME}.out" 2>/dev/null
exit 2
;;
list)
echo "=== agent windows in session '$SESSION' ==="
TMUX= tmux list-windows -t "$SESSION" -F "#{window_index}: #{window_name}" 2>/dev/null | while read -r line; do
wn="${line#*: }"
if [[ -f "$RUNDIR/${wn}.done" ]]; then st="done(exit $(cat "$RUNDIR/${wn}.done" 2>/dev/null | tr -d '[:space:]'))"; else st="running"; fi
echo " $line [$st]"
done
[[ -z "$(TMUX= tmux list-windows -t "$SESSION" 2>/dev/null)" ]] && echo "(no session)"
;;
kill)
NAME="${2:?usage: mav-agent kill <name>}"
TMUX= tmux kill-window -t "${SESSION}:${NAME}" 2>/dev/null && echo "killed: $NAME" || echo "not found: $NAME"
;;
*)
echo "usage: mav-agent run <name> [--codex] <task> | read <name> | status <name> | wait <name> [s] | result <name> | list | kill <name>"
exit 1
;;
esac

35
bin/mav-tmux Executable file
View File

@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# mav-tmux — run a command in the persistent mav tmux session and capture output
# usage: mav-tmux run "command"
# mav-tmux read (capture current pane output)
# mav-tmux clear (clear the pane)
SESSION="mav"
# ensure session exists
TMUX= tmux has-session -t "$SESSION" 2>/dev/null || TMUX= tmux new-session -d -s "$SESSION"
case "${1:-read}" in
run)
CMD="${2:?usage: mav-tmux run <command>}"
MARKER="__DONE_$(date +%s%N)__"
TMUX= tmux send-keys -t "$SESSION" "$CMD; echo $MARKER" Enter
# wait for marker
for i in $(seq 1 60); do
sleep 0.5
OUT=$(TMUX= tmux capture-pane -t "$SESSION" -p 2>/dev/null)
if echo "$OUT" | grep -q "$MARKER"; then
echo "$OUT" | sed "/$MARKER/d" | sed '/^$/d' | tail -30
break
fi
done
;;
read)
TMUX= tmux capture-pane -t "$SESSION" -p 2>/dev/null
;;
clear)
TMUX= tmux send-keys -t "$SESSION" "clear" Enter
;;
*)
echo "usage: mav-tmux run <cmd> | read | clear"
;;
esac

28
install.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# install.sh — drop the orchestrate skill + wrappers into the current user's env.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$HOME/.claude/skills/orchestrate"
BIN_DIR="$HOME/.local/bin"
mkdir -p "$SKILL_DIR" "$BIN_DIR"
cp "$HERE/skill/SKILL.md" "$SKILL_DIR/SKILL.md"
cp "$HERE/bin/mav-agent" "$BIN_DIR/mav-agent"
cp "$HERE/bin/mav-tmux" "$BIN_DIR/mav-tmux"
chmod +x "$BIN_DIR/mav-agent" "$BIN_DIR/mav-tmux"
echo "installed:"
echo " skill -> $SKILL_DIR/SKILL.md"
echo " mav-agent -> $BIN_DIR/mav-agent"
echo " mav-tmux -> $BIN_DIR/mav-tmux"
case ":$PATH:" in
*":$BIN_DIR:"*) ;;
*) echo "NOTE: add $BIN_DIR to your PATH to use mav-agent/mav-tmux." ;;
esac
command -v tmux >/dev/null 2>&1 || echo "WARN: tmux not found — required."
command -v claude >/dev/null 2>&1 || echo "NOTE: claude not on PATH (needed for claude agents)."
command -v codex >/dev/null 2>&1 || echo "NOTE: codex not on PATH (needed for --codex agents)."
echo "done. /orchestrate is now available in Claude Code."

106
skill/SKILL.md Normal file
View File

@@ -0,0 +1,106 @@
---
name: orchestrate
description: >-
The main-actor pattern for bot teammates. Use when YOU are a bot wired to a
chat (Telegram, etc.) and a request needs real coding/build work. You stay the
ONLY voice in chat and never block — you delegate the heavy work to persistent
tmux sub-agents (mav-agent), poll their done-marker, and report results back.
Trigger when: a chat request implies a long-running build/code task, when you
feel tempted to do heavy work inline and risk freezing the conversation, or
when the user asks to "spawn an agent", "run this in the background", or
"delegate" something.
---
# Orchestrate — one voice talks, hands stay clean
You are the **main actor**: the single process that owns the chat. Your job is to
read messages, decide, reply, and **delegate** anything heavy. You must never
block the conversation on a long build. Coding/builds happen in **sub-agents**
that live in their own tmux windows and persist across turns — even if your own
session gets compacted or restarted.
## The rule
- **You** = the only thing that touches the chat. Fast turns, never blocks.
- **Sub-agents** = do the actual coding/building in tmux. They outlive your turn.
- Never run a long task inline. Spawn it, then poll the marker, then report.
## Tools
Two thin wrappers around a persistent tmux session named `mav`:
- `mav-agent` — spawn/observe named claude or codex sub-agents (one tmux window each).
- `mav-tmux` — run a one-off shell command in the shared session and capture output.
### mav-agent
```
mav-agent run <name> <task> spawn a claude sub-agent (model: opus)
mav-agent run <name> --codex <task> spawn a codex sub-agent
mav-agent status <name> running | done (exit N) | none
mav-agent wait <name> [timeout_s] block until done (marker-based), prints tail
mav-agent result <name> print the agent's full final stdout
mav-agent read <name> live snapshot of the agent's pane
mav-agent list all agent windows + their status
mav-agent kill <name> kill an agent window
```
### The clean handoff (why this is reliable)
Every `run` wraps the agent so that when the CLI exits it writes:
- `/tmp/mav-agent/<name>.out` — full stdout/stderr stream
- `/tmp/mav-agent/<name>.done` — exit code (written ONLY when finished)
`status` / `wait` poll the `.done` marker — **no prompt-sniffing, no guessing**.
Marker present ⇒ work is finished. Absent + window alive ⇒ still running.
This is the gotcha that breaks naive setups: without a real done-signal the main
actor either reports too early or hangs forever. The marker fixes both.
## Playbook (the loop you run every time)
1. **Acknowledge fast.** Reply in chat that you're on it. Don't make the user wait
on a silent turn.
2. **Spawn.** `mav-agent run <name> "<self-contained task with full context>"`.
The sub-agent does NOT share your memory — put everything it needs in the task.
3. **Don't block your turn.** For short work, `mav-agent wait <name> 60`. For long
work, end your turn; on the next wake check `mav-agent status <name>`.
4. **On done:** `mav-agent result <name>`, summarize, reply in chat. Check the
exit code — non-zero means it failed; read `result` and decide a fix or retry.
5. **Clean up** finished windows with `mav-agent kill <name>` so `list` stays readable.
## Conventions
- **Name agents by job**, not sequence: `auth-fix`, `icon-gen`, `db-migrate`.
- **Self-contained tasks.** Sub-agents start cold — no chat history, no your-memory.
Spell out the repo path, the goal, and the done-condition in the task string.
- **One job per agent.** Parallel jobs ⇒ parallel names; they run concurrently.
- **Claude vs codex:** claude (`opus`) for reasoning/multi-file work; `--codex` for
fast scoped edits. Both honor the same status/wait/result handoff.
- **Long-lived processes** (servers, watchers, `npm run dev`) go in `mav-tmux run`,
not an agent — they're meant to keep running, not finish.
## Anti-patterns
- ❌ Doing the build inline in your own turn — freezes the chat, dies on compaction.
- ❌ Polling `read` and eyeballing for a shell prompt — flaky. Use `status`/`wait`.
- ❌ Terse tasks like `"fix the bug"` — the agent has none of your context. Be explicit.
- ❌ Leaving dozens of dead windows around — `kill` them after you read the result.
## Quick example
```bash
# chat: "regenerate the app icons in /Users/m1/proj at 3 sizes"
mav-agent run icon-gen "In /Users/m1/proj, regenerate app icons at 1x/2x/3x \
from assets/icon.svg into assets/icons/. Use the existing gen script if present. \
Print the output paths when done."
mav-agent status icon-gen # -> running
# ... end turn, or wait ...
mav-agent wait icon-gen 120 # -> [done] exit 0 + tail
mav-agent result icon-gen # -> full log to summarize back to chat
mav-agent kill icon-gen
```
That's the whole pattern: one voice in chat, real work in persistent agents, a
file marker for the handoff. Any bot wired to a chat can adopt it by following
this loop.