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:
53
README.md
Normal file
53
README.md
Normal 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
126
bin/mav-agent
Executable 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
35
bin/mav-tmux
Executable 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
28
install.sh
Executable 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
106
skill/SKILL.md
Normal 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.
|
||||||
Reference in New Issue
Block a user