commit f5097928720adac8897ea230ce4f1f8f7273dc08 Author: mav Date: Fri May 29 11:31:15 2026 +0200 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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..c7cea36 --- /dev/null +++ b/README.md @@ -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/.out` — full stdout/stderr +- `/tmp/mav-agent/.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 ""` +3. `mav-agent wait 60` (short) or end turn + `mav-agent status ` next wake. +4. On done: `mav-agent result `, summarize, reply. Check the exit code. +5. `mav-agent kill ` once you've read it. + +See `skill/SKILL.md` for the full reference. diff --git a/bin/mav-agent b/bin/mav-agent new file mode 100755 index 0000000..d406450 --- /dev/null +++ b/bin/mav-agent @@ -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 spawn a claude sub-agent with task +# mav-agent run --codex spawn a codex sub-agent with task +# mav-agent read capture current output from agent window +# mav-agent status running | done | none (reliable, marker-based) +# mav-agent wait [timeout_s] block until agent done (marker-based) +# mav-agent result print the agent's final stdout (after done) +# mav-agent list list all agent windows + status +# mav-agent kill kill an agent window +# +# agents run in mav tmux session, window named . +# clean handoff: each run writes /tmp/mav-agent-.done when the CLI exits, +# and streams full stdout to /tmp/mav-agent-.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 [--codex] }" + 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 }" + 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 }" + 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 }" + 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 [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 }" + TMUX= tmux kill-window -t "${SESSION}:${NAME}" 2>/dev/null && echo "killed: $NAME" || echo "not found: $NAME" + ;; + + *) + echo "usage: mav-agent run [--codex] | read | status | wait [s] | result | list | kill " + exit 1 + ;; +esac diff --git a/bin/mav-tmux b/bin/mav-tmux new file mode 100755 index 0000000..a00c1c0 --- /dev/null +++ b/bin/mav-tmux @@ -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 }" + 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 | read | clear" + ;; +esac diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..7a588c3 --- /dev/null +++ b/install.sh @@ -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." diff --git a/skill/SKILL.md b/skill/SKILL.md new file mode 100644 index 0000000..1969e15 --- /dev/null +++ b/skill/SKILL.md @@ -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 spawn a claude sub-agent (model: opus) +mav-agent run --codex spawn a codex sub-agent +mav-agent status running | done (exit N) | none +mav-agent wait [timeout_s] block until done (marker-based), prints tail +mav-agent result print the agent's full final stdout +mav-agent read live snapshot of the agent's pane +mav-agent list all agent windows + their status +mav-agent kill 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/.out` — full stdout/stderr stream +- `/tmp/mav-agent/.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 ""`. + 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 60`. For long + work, end your turn; on the next wake check `mav-agent status `. +4. **On done:** `mav-agent result `, 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 ` 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.