bot-run · diff

git:20260918.67ac52a to git:20260918.ea5f80c

23 added, 3 removed. Audit A to A.

---
name: bot-run
description: "How to run an @bot line: delegate it to its own session and wait for the result."
hidden: true
---
# Running an @bot line
Every `[subagent·@name]` line in the message is that Bot's work, not yours. Hand it to a session of
its own and wait for the answer — you are the dispatcher for these lines, not the executor.
Do **not** read the Bot's `BOT.md` or `bot-turn` yourself. They are listed at the end of the
message under "pass the path along; do not open them yourself" — that is the only reason you have
those paths. The child reads them and works from them; keeping them out of this session is the
point.
## 1. Write a self-contained brief
The child sees none of this conversation. Everything it needs goes in the brief: the user's
intent in their own words, the context you have accumulated here (which repository, which files
are in scope, what was already decided, what it should NOT re-litigate), and what "done" looks
like. This is the whole value of delegating from here rather than letting the user type it — the
brief you write is the part the user did not have to.
Start it with both paths, in this order — the general contract, then the particular Bot:
> Read `<the bot-turn path>` for how a Bot turn works, then `<the Bot's BOT.md path>` for who this
> Bot is and which of its files to read. Follow its "Before working" section, then do the task in
> the voice its identity files define.
Both paths are in the list at the end of the message. Pass them verbatim; a child that is missing
`bot-turn` has no write-lock protocol and will invent one.
**Write only what you learned here; never assert what the Bot remembers.** You have not read its
files — it has. A brief that says "this is already settled in its memory" is a guess dressed as a
citation, and the child will take it as a premise it may not re-open. Say where a constraint
actually came from ("the user said so in this conversation"), and leave its memory to it.
## 2. Delegate — fire every @bot line before waiting on any of them
```bash
BRIEF=$(mktemp /tmp/cockpit-brief.XXXXXX)
cat > "$BRIEF" <<'EOF_BRIEF'
<the brief>
EOF_BRIEF
curl -sS --fail-with-body -X POST "{{BASE_URL}}/api/sessions/delegate" \
-H "Content-Type: application/json" -H "x-cockpit-run-id: $COCKPIT_RUN_ID" \
--data-binary @- <<EOF_JSON
- {"cwd": "<this project's cwd>", "title": "@<name>: <short label>", "briefPath": "$BRIEF"}
+ {"cwd": "${COCKPIT_CWD:-$PWD}", "title": "@<name>: <short label>", "briefPath": "$BRIEF"}
EOF_JSON
```
The body is `{ cwd, engine?, model?, title?, prompt? | briefPath? }`:
- **Do not delete the brief file.** The POST returns before the child has read it: the endpoint
only checks that the file exists, and the child is handed the *path*. Tidying up `$BRIEF` after
the call leaves it with a task it cannot read.
- - `cwd` is **this** project, not the Bot directory — the Bot reaches its own files by absolute
- path, and the work usually needs the project. It must be an existing directory.
+ - **`cwd` has two right answers, decided by what the line asks for:**
+
+ | The `@name` line is | `cwd` |
+ |---|---|
+ | ordinary work | `${COCKPIT_CWD:-$PWD}` — exactly as written above, a variable you never resolve yourself |
+ | "导出 / export", "复盘 / review", "装上 / attach" | the Bot's own directory: `dirname` of the BOT.md path listed at the end of the message |
+
+ Ordinary work happens in the project, and the Bot reaches its own files by absolute path. The
+ three maintenance verbs are work *on the Bot*: its files are the subject, its `.locks/` is
+ there, and a review writes `.reviews/` into it — a child sitting in some unrelated project would
+ reach all of that by absolute path for no reason, and `git` inside the Bot's own repository
+ would not work at all. Taking `dirname` of that path is not opening it; the rule against reading
+ it stands.
+
+ **`COCKPIT_CWD` is this session's own working directory, exported by Cockpit.** Leave it as a
+ variable in the JSON — the heredoc expands it. Never substitute a path you worked out instead:
+ not the git root, not the nearest directory with a CLAUDE.md, not any parent that looks more
+ like a project than where you are. Doing that silently moves the child one or more levels above
+ where the user is working, and its transcript then lands under a different project in the
+ session list — which is what happened when a session working inside a subdirectory delegated a
+ child at the repository root instead. It is also not `pwd`: `pwd` follows any `cd` this turn has
+ made, which is why the fallback is only there for a Cockpit too old to export the variable.
- Leave `engine` and `model` unset. Bot manifests expose only `name` and `description`, and the
dispatcher must not open `BOT.md` to invent an engine preference.
- `title` is optional but always worth setting — it is how the session is listed later.
- For a one-line task you may send `"prompt": "..."` instead of `briefPath` (escape `"` and `\`).
A Bot task rarely is one: the brief carries the accumulated context, which is the whole point.
- The receipt comes back at once: `{ cwd, engine, model?, sessionId, link, title, createdAt,
parent? }`. Post every Bot's brief first, then wait.
- A 400 names the problem — cwd missing, relative, or not a directory; a brief file that does not
exist; too many delegated sessions already running. Report what it says; do not retry blindly.
## 3. Wait
A Bot turn routinely outlasts a single bash call's tool timeout — a working-tree code review
measured 11 minutes against a 10-minute ceiling — so the wait has to survive that. Two ways, in
order of preference:
**Run it in the background if your Bash tool can** (`run_in_background`, a job that notifies you
on exit, or equivalent). Set `BUDGET` high (3600) and launch once: the tool timeout stops applying,
you get woken when it finishes, and you are free in the meantime. This is what to reach for first.
**Otherwise wait in chunks.** Foreground, `BUDGET` about 10% under your Bash timeout (600s max →
`BUDGET=540`; can't raise it → assume 120s and use `BUDGET=100`). Each call prints where things
stand and exits; if anything is still running, run the same block again with the remaining ids.
That is the normal path, not a failure.
```bash
CWD='<the receipt's cwd, verbatim>'
SIDS='<sessionId>
<sessionId>' # still-running ids, ONE PER LINE
BUDGET=540 # seconds THIS call may spend
started=$(date +%s)
deadline=$(( started + BUDGET ))
while :; do
pending=''
for sid in $(printf '%s\n' "$SIDS"); do
body=$(curl -sS --fail-with-body --get "{{BASE_URL}}/api/sessions/status" \
--data-urlencode "cwd=$CWD" --data-urlencode "sessionId=$sid") || body='{}'
state=$(printf '%s' "$body" | python3 -c 'import json,sys
try: print(json.load(sys.stdin).get("status","?"))
except Exception: print("?")')
echo "+$(( $(date +%s) - started ))s $sid -> $state"
# `?` = the status call failed, NOT a terminal state. Keep it pending so a
# blip retries and a real mistake shows up as a stall, never as "finished".
if [ "$state" = running ] || [ "$state" = '?' ]; then pending="$pending
$sid"; fi
done
SIDS=$(printf '%s\n' "$pending" | grep -v '^$')
[ -z "$SIDS" ] && break
[ "$(date +%s)" -ge "$deadline" ] && break
if [ "$(( $(date +%s) - started ))" -lt 30 ]; then sleep 5; else sleep 15; fi
done
echo "still running: ${SIDS:-none}"
```
- **`still running: <ids>`** → the budget ran out first. Run the block again with `SIDS` set to
exactly those ids. Nothing is wrong.
- **`still running: none`** → every Bot reached a terminal status. The last `+Ns <sid> -> <state>`
line for each is that status; read it before writing the report.
- **Any `-> ?` line** → that status call failed and the id stays pending. If it repeats, the call
is wrong, not the session — see below.
- One loop covers every Bot, so they run in parallel and each drops out as it finishes.
- Keep the ids newline-separated and keep `$(printf '%s\n' "$SIDS")` — a plain `for sid in $SIDS`
reads as a single id under zsh, which does not word-split parameters.
**There is no fixed cap.** Keep waiting while the user is waiting on the answer. For scale: two
measured working-tree code reviews took 7 and 11 minutes; a Bot review sweep reads a whole
directory and takes longer. Once it has run past roughly half an hour, stop waiting silently — say
it is still going, hand over the links, and let the user decide.
**Never state an outcome you have not seen a non-`running` status for.** Hand-rolled variants of
this check go wrong in one direction only: they exit immediately and you report a result the child
never produced.
`done` = the last turn finished normally; `failed` = the engine recorded an error;
`incomplete` = stopped or interrupted, and the user can open it and carry on.
**`?` is not a status.** It means the status call itself failed — wrong `cwd`, wrong id, server
gone. The loop keeps such an id pending rather than counting it finished, so a transient blip
just retries; an id stuck on `?` for a whole budget means the call is wrong. Re-run the bare curl
without `|| body='{}'`, read the error body, and fix it. Never report `?` as an outcome.
## 4. Report
One short section per Bot: its conclusion in a few lines, then `[open session](<link>)`. The full
answer lives in that session — do not paste it wholesale, and do not restate it as your own.
`lastReply` is cut at 4000 characters; when `lastReplyTruncated` is true and you need the rest,
repeat the status call with `--data-urlencode "full=1"`. Use the receipt's `link` exactly as
returned — it is the only record of the delegation, and tool output is not searchable later.
**A numbered list is a special case.** Some Bot tasks (a review sweep, for instance) end in a list
of findings the user picks from. Relay it whole with its numbering intact, and keep whatever path
the child saved it to — the user answers either in that session or by naming that file in a new
one, and dropping either detail strands the work.
Pressing stop here does not stop a delegated session — it is a separate run. Say so if the user
expects otherwise.