- description
- What Claude Code (or Claude dispatch) follows to install the d2 full harness on a Mac or Linux box: three keepers that run the monitor, the dispatcher (which starts coders) and the designer in the background (P-1278). Served filled in by /setup/<id> (P-1307); {{...}} are filled there. razie publishes.
- tags
- #skill #setup
Install the d2 full harness for {{project}}¶✎ edit
You are Claude Code (or Claude dispatch) on your person's machine. Install three keepers, one per role: the monitor, the dispatcher and the designer. Each keeper waits on d2 (https://{{host}}) for work for its role, starts one Claude Code pass per piece of work, and keeps it going. That covers restarts with backoff, a fresh session at the token budget, and an alert when it can't fix something itself. The dispatcher starts the coders itself, as its skill says. All three keepers run the same script.
Do the steps in order. Write every file exactly as given. Never print, echo or show a token, not even to your person. If a step fails, stop and tell them which step and what it answered.
1. Check the machine¶✎ edit
command -v claudemust find Claude Code, andclaude -p "say ok"must answer. If not, tell your person to install Claude Code and sign in, then stop.python3 --versionandgit --versionmust work. On a Mac without them:xcode-select --install, then go on.- macOS uses launchd (step 5a), Linux uses systemd user units (step 5b).
2. Make the folders¶✎ edit
For each role R in monitor dispatcher designer:
D="$HOME/d2/harness/{{project}}-$R"; mkdir -p "$D" && chmod 700 "$D"
If a folder already holds a keeper, this is an upgrade. Stop it first (step 6, Stop), then go on: the steps overwrite the scripts and keep state/ and work/.
3. Claim the tokens, once¶✎ edit
These links work once each, until {{expires}}:
{{claims}}
For each claim line, with R its role (and builder claimed into the dispatcher's folder as coder.token):
cd "$HOME/d2/harness/{{project}}-$R" && umask 077
curl -s -X POST -H 'Content-Type: application/json' -d "{\"name\":\"$R\"}" <that role's claim url> > claim.json
python3 -c 'import json; d = json.load(open("claim.json")); open("role.token", "w").write(d["token"]); open("agent.env", "w").write("T=%s\nNAME=%s\n" % (d["agent"]["token"], d["agent"]["name"]))'
rm claim.json && chmod 600 role.token agent.env
For builder, keep only the role token, as coder.token in the dispatcher's folder (the dispatcher mints its coders from it):
cd "$HOME/d2/harness/{{project}}-dispatcher" && umask 077
curl -s -X POST -H 'Content-Type: application/json' -d '{"name":"coder"}' <the builder claim url> > claim.json
python3 -c 'import json; open("coder.token", "w").write(json.load(open("claim.json"))["token"])'
rm claim.json && chmod 600 coder.token
A 410 E_GONE means a link was used or has expired: ask your person for a new setup link from their Monitor page (https://{{host}}/ai/monitor).
4. Write the files¶✎ edit
In each role's folder D, write keeper.conf with that role:
HOST={{host}}
PROJECT={{project}}
ROLE=<monitor | dispatcher | designer>
MODE=full
and pass.md, with that role in place of <role>:
You are the <role> on {{project}} (https://{{host}}), one background pass started by your keeper. Your agent token is in $D2_TOKEN and your name in $D2_AGENT: send "Authorization: Bearer $D2_TOKEN" and "D2-Agent: $D2_AGENT" on every call, and never print the token.
Read your role skill, GET https://{{host}}/api/v2/skills/d3?role=<role>, and follow its "In the background" workflow. Post your status with "startedBy": "keeper". Work at most one item or batch this pass, answering a chat first and then your mail. Post a true status (every 86400 when you end idle or done) and exit. Never wait or loop yourself: the keeper starts the next pass.
At 80% of your context, write a handover (your skill's handover_write), post handing-over and exit: the keeper starts a fresh session on it.
The dispatcher's pass.md gets one more line:
Your coders' role token is in the file coder.token beside this folder's keeper.conf (../coder.token from work/): mint each coder's agent token from it, as your skill says, and never print it. Coders work in folders under work/.
Then write keeper.sh in each folder, exactly the same in all three:
#!/bin/bash
# d2 keeper (Skill:d3-setup-solo / Skill:d3-setup-full). One keeper per role: it waits on d2 for work for that role and
# starts one Claude Code pass per piece of work, on one stable session, rotated at the token budget.
# bash keeper.sh <dir> run (launchd / systemd keep it alive)
# bash keeper.sh <dir> check one check, prints OK lines or what is wrong, then exits
# <dir> holds keeper.conf (HOST PROJECT ROLE MODE), role.token and agent.env (T=, NAME=), all mode 600.
set -u
D=${1:?usage: keeper.sh <dir> [check]}; CHECK=${2:-}
. "$D/keeper.conf" # HOST PROJECT ROLE MODE [BUDGET PASS_MAX SAME_MIN]
API="https://$HOST/api/v2"; S="$D/state"; L="$D/logs"; W="$D/work"; mkdir -p "$S" "$L" "$W"
BUDGET=${BUDGET:-600000}; PASS_MAX=${PASS_MAX:-3600}; SAME_MIN=${SAME_MIN:-15}
CLAUDE=${CLAUDE:-$(command -v claude || echo "$HOME/.claude/local/claude")}
now() { date +%s; }
log() { echo "$(date -u +%FT%TZ) $*" >> "$L/keeper.log"; }
notify() { # a desktop notification: macOS, else Linux, else nothing
osascript -e "display notification \"$2\" with title \"d2 keeper ($ROLE): $1\"" 2>/dev/null \
|| notify-send "d2 keeper ($ROLE): $1" "$2" 2>/dev/null || true
}
call() { # call <method> <path> [body-file]: answers the HTTP status, the body in $S/out.json
. "$D/agent.env"
curl -s --max-time 40 -o "$S/out.json" -w '%{http_code}' -X "$1" -H "Authorization: Bearer $T" -H "D2-Agent: $NAME" \
-H 'Content-Type: application/json' ${3:+--data-binary @"$3"} "$API$2" || echo 000
}
alert() { # alert <kind> <text>: once an hour per kind; d2 raises its flag and mails the person; a desktop notification
local f="$S/alerted.$1"
[ -f "$f" ] && [ $(( $(now) - $(cat "$f") )) -lt 3600 ] && return
now > "$f"; log "ALERT $1: $2"; notify "$1" "$2"
python3 -c 'import json,sys; print(json.dumps({"kind": sys.argv[1], "text": sys.argv[2]}))' "$1" "$2" > "$S/alert.json"
call POST /agents/alert "$S/alert.json" > /dev/null
}
cleared() { rm -f "$S/alerted.$1"; }
mint() { # a new agent token from the role token, into agent.env (600); never printed
printf '{"name":"%s"}' "$ROLE" > "$S/mint.json"
local c; c=$(curl -s --max-time 40 -o "$S/minted.json" -w '%{http_code}' -X POST -H "Authorization: Bearer $(cat "$D/role.token")" \
-H 'Content-Type: application/json' --data-binary @"$S/mint.json" "$API/tokens/agent" || echo 000)
[ "$c" = 201 ] || { rm -f "$S/minted.json"; return 1; }
( umask 077; python3 -c 'import json,sys; d = json.load(open(sys.argv[1])); print("T=%s\nNAME=%s" % (d["token"], d["agent"]))' \
"$S/minted.json" > "$D/agent.env.new" ) && mv "$D/agent.env.new" "$D/agent.env"
rm -f "$S/minted.json"; log "minted a new agent token: $(. "$D/agent.env"; echo "$NAME")"
}
ctx() { # ctx <session id>: the session's context in tokens (its last turn's input), 0 when unknown
local f; f=$(ls -t "$HOME"/.claude/projects/*/"$1".jsonl 2>/dev/null | head -1); [ -n "$f" ] || { echo 0; return; }
tail -c 2000000 "$f" | python3 -c '
import json, sys
n = 0
for l in sys.stdin:
try: d = json.loads(l)
except Exception: continue
u = (d.get("message") or {}).get("usage") if d.get("type") == "assistant" else None
if u: n = (u.get("input_tokens") or 0) + (u.get("cache_read_input_tokens") or 0) + (u.get("cache_creation_input_tokens") or 0)
print(n)'
}
status() { # status <state> <text>: the keeper's own line on the board, as the role's agent
python3 -c 'import json,sys; print(json.dumps({"agent": sys.argv[1], "role": sys.argv[2], "state": sys.argv[3], "text": sys.argv[4], "context": 0, "every": 3600, "startedBy": "keeper"}))' \
"$(. "$D/agent.env"; echo "$NAME")" "$ROLE" "$1" "$2" > "$S/status.json"
call POST /agents/status "$S/status.json"
}
pass() { # pass <next.json>: one Claude Code pass, bounded by PASS_MAX; sets RC and LOGF
local sid flag first="" chat
sid=$(cat "$S/session" 2>/dev/null || true)
if [ -n "$sid" ] && [ "$(ctx "$sid")" -ge "$BUDGET" ]; then
log "session $sid at the budget: a fresh session takes its handover"; mv "$S/session" "$S/session.$(now)"; sid=""; first=1
fi
if [ -z "$sid" ]; then sid=$(uuidgen | tr 'A-Z' 'a-z'); echo "$sid" > "$S/session"; flag=--session-id; else flag=--resume; fi
chat=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("chat") or "")' "$1" 2>/dev/null)
{ cat "$D/pass.md"
[ -n "$first" ] && printf '\nYou are a fresh session: take your handover first (your skill: handover_take).\n'
[ -n "$chat" ] && printf '\nA live chat waits on you: %s. Join it and answer it first.\n' "$chat"
printf '\nWhat d2 says waits for you: %s\n' "$(cat "$1")"; } > "$S/prompt.txt"
LOGF="$L/pass-$(date -u +%Y%m%dT%H%M%SZ).log"
. "$D/agent.env"
( cd "$W" && D2_TOKEN="$T" D2_AGENT="$NAME" D2_HOST="$HOST" \
"$CLAUDE" -p "$(cat "$S/prompt.txt")" "$flag" "$sid" --permission-mode acceptEdits --allowedTools "Bash,Read,Edit,Write" \
> "$LOGF" 2>&1 ) &
local pid=$! waited=0
while kill -0 "$pid" 2>/dev/null; do
sleep 10; waited=$((waited + 10))
[ "$waited" -ge "$PASS_MAX" ] && { log "pass ran over $PASS_MAX s: stopped"; kill "$pid" 2>/dev/null; }
done
wait "$pid"; RC=$?; log "pass ended rc=$RC ($LOGF)"
ls -t "$L"/pass-*.log 2>/dev/null | tail -n +51 | xargs rm -f # keep the last 50 pass logs
}
failed() { # failed: why the last pass failed, from its log, and what to do about it
if grep -qiE "usage limit|limit reached|resets at|rate.?limit" "$LOGF"; then
alert spend "Claude's usage limit is reached. The keeper waits an hour and tries again; nothing to do unless you want to raise your plan."
now | awk '{print $1 + 3600}' > "$S/backoff.until"; return
fi
if grep -qiE "invalid api key|please run /login|not logged in|authentication" "$LOGF"; then
alert other "Claude Code is signed out on this machine. Open a terminal, run: claude login. The keeper starts again by itself."
now | awk '{print $1 + 600}' > "$S/backoff.until"; return
fi
local n; n=$(( $(cat "$S/fails" 2>/dev/null || echo 0) + 1 )); echo "$n" > "$S/fails"
case "$n" in 1) w=30;; 2) w=120;; 3) w=600;; *) w=1800;; esac
[ "$n" -eq 3 ] && { log "3 failed passes: claude update"; "$CLAUDE" update >> "$L/keeper.log" 2>&1 || true; }
[ "$n" -ge 4 ] && alert crash "Claude Code keeps failing ($n passes in a row). The keeper retries every 30 minutes. Look at $LOGF, or run: claude doctor."
now | awk -v w="$w" '{print $1 + w}' > "$S/backoff.until"
}
# ---- check: what the installer runs last ----
if [ "$CHECK" = check ]; then
[ -x "$CLAUDE" ] || command -v "$CLAUDE" >/dev/null || { echo "NO: claude not found"; exit 1; }; echo "OK claude: $CLAUDE"
command -v python3 >/dev/null || { echo "NO: python3 not found"; exit 1; }; echo "OK python3"
for f in keeper.conf role.token agent.env pass.md; do
[ -f "$D/$f" ] || { echo "NO: $D/$f missing"; exit 1; }
done
[ "$(stat -f %Lp "$D/role.token" 2>/dev/null || stat -c %a "$D/role.token")" = 600 ] || { echo "NO: role.token is not mode 600"; exit 1; }
echo "OK files"
c=$(status up "keeper installed on $(hostname -s), $MODE"); [ "$c" = 200 ] || { echo "NO: d2 answered $c to the status post"; exit 1; }
echo "OK d2: posted up as $(. "$D/agent.env"; echo "$NAME")"; exit 0
fi
# ---- run: one keeper at a time per dir ----
if ! mkdir "$S/lock" 2>/dev/null; then
p=$(cat "$S/lock/pid" 2>/dev/null); kill -0 "$p" 2>/dev/null && exit 0; rm -rf "$S/lock"; mkdir "$S/lock"
fi
echo $$ > "$S/lock/pid"; trap 'rm -rf "$S/lock"' EXIT
log "keeper up ($MODE, $ROLE on $PROJECT)"
while :; do
[ -f "$D/stop" ] && { sleep 60; continue; } # touch <dir>/stop to pause, rm it to go on
u=$(cat "$S/backoff.until" 2>/dev/null || echo 0); [ "$(now)" -lt "$u" ] && { sleep 30; continue; }
c=$(call GET "/agents/next?wait=25")
case "$c" in
204) cleared down; rm -f "$S/down.since"; continue ;;
200) cleared down; rm -f "$S/down.since" ;;
401) log "agent token refused: minting a new one"
if mint; then cleared token; else
alert token "Your d2 $ROLE token for $PROJECT no longer works. On https://$HOST/ai/monitor make a new setup link and give it to Claude; it reinstalls in place."
sleep 300; fi
continue ;;
000|5*) [ -f "$S/down.since" ] || now > "$S/down.since"
[ $(( $(now) - $(cat "$S/down.since") )) -ge 600 ] && alert down "d2 ($HOST) has not answered for 10 minutes. The keeper keeps trying; nothing to do."
sleep 60; continue ;;
*) log "next answered $c: $(head -c 300 "$S/out.json")"; sleep 60; continue ;;
esac
cp "$S/out.json" "$S/next.json"
sig=$(python3 -c 'import json,sys; d = json.load(open(sys.argv[1])); print(json.dumps([d.get("pickable"), d.get("mail"), d.get("chat")], sort_keys=True))' "$S/next.json")
last=$(cat "$S/last.at" 2>/dev/null || echo 0)
if [ "$sig" = "$(cat "$S/last.sig" 2>/dev/null)" ] && [ $(( $(now) - last )) -lt $((SAME_MIN * 60)) ]; then sleep 60; continue; fi
echo "$sig" > "$S/last.sig"; now > "$S/last.at"
pass "$S/next.json"
if [ "$RC" -eq 0 ]; then rm -f "$S/fails"; cleared crash; else failed; fi
done
Then in each folder run chmod 700 keeper.sh && chmod 600 keeper.conf pass.md.
5a. Keep them running on a Mac (launchd)¶✎ edit
One plist per role R, the same as below with R filled in.
Write ~/Library/LaunchAgents/com.d2.keeper.{{project}}-R.plist, putting the real $D and $HOME in place of the two paths:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>com.d2.keeper.{{project}}-R</string>
<key>ProgramArguments</key><array><string>/bin/bash</string><string>$D/keeper.sh</string><string>$D</string></array>
<key>EnvironmentVariables</key><dict><key>PATH</key><string>$HOME/.local/bin:$HOME/.claude/local:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string></dict>
<key>KeepAlive</key><true/>
<key>RunAtLoad</key><true/>
<key>ThrottleInterval</key><integer>30</integer>
<key>AbandonProcessGroup</key><true/>
<key>StandardOutPath</key><string>$D/logs/launchd.out</string>
<key>StandardErrorPath</key><string>$D/logs/launchd.err</string>
</dict></plist>
Make sure the folder of command -v claude is in that PATH. Then: launchctl bootout gui/$(id -u)/com.d2.keeper.{{project}}-R 2>/dev/null; launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.d2.keeper.{{project}}-R.plist.
5b. Keep them running on Linux (systemd, user)¶✎ edit
One unit per role R, the same as below with R filled in.
Write ~/.config/systemd/user/d2-keeper-{{project}}-R.service:
[Unit]
Description=d2 keeper {{project}} R
After=network-online.target
[Service]
ExecStart=/bin/bash %h/d2/harness/{{project}}-R/keeper.sh %h/d2/harness/{{project}}-R
Environment=PATH=%h/.local/bin:%h/.claude/local:/usr/local/bin:/usr/bin:/bin
Restart=always
RestartSec=30
[Install]
WantedBy=default.target
Then systemctl --user daemon-reload && systemctl --user enable --now d2-keeper-{{project}}-R and loginctl enable-linger "$USER", so it runs when nobody is logged in.
6. Check, and tell your person¶✎ edit
For each role's folder D, run bash "$D/keeper.sh" "$D" check. Every line must start with OK. Then tell your person, in a few lines:
- It's installed: three keepers start the monitor, the dispatcher and the designer when there is work, mail or a chat for them. They show on https://{{host}}/agents; the dispatcher starts coders when the coder lane has work.
- Pause:
touch $D/stop(remove the file to go on). Stop:launchctl bootout gui/$(id -u)/com.d2.keeper.{{project}}-R(Linux:systemctl --user disable --now d2-keeper-{{project}}-R). Logs:$D/logs/. - When something breaks it can't fix, they get a red flag on d2, an email and a desktop notification. What to do is on https://{{host}}/help/Keeper.
When something goes wrong¶✎ edit
| What | The keeper does | Your person does |
|---|---|---|
| d2 down or no network | retries every minute; alerts after 10 min (desktop only, since d2 can't flag) | nothing |
| agent token expired or revoked | mints a new one from the role token | nothing |
| role token dead (revoked, expired) | alerts token and waits | makes a new setup link on the Monitor page and gives it to Claude (reinstalls in place) |
| Claude usage limit | waits an hour and retries; alerts spend once | nothing, or raises the plan |
| Claude Code signed out | alerts and waits | runs claude login |
| passes keep failing | backoff 30 s, 2 min, 10 min, then every 30 min; runs claude update at the 3rd; alerts crash at the 4th |
reads the pass log, runs claude doctor |
| the Mac slept | carries on when it wakes | nothing |
| a pass runs over an hour | stops it; the next pass resumes the session | nothing |
| the session is full (600k tokens) | the designer hands over; the keeper starts a fresh session on it | nothing |