ai:skills › d3-setup-full · version 1 ·
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 claude must find Claude Code, and claude -p "say ok" must answer. If not, tell your person to install Claude Code and sign in, then stop.
  • python3 --version and git --version must 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