| 1 | # claude-dispatch |
| 2 | |
| 3 | HMAC-signed, file-system-mediated job dispatch between two agent sessions |
| 4 | on different hosts. Built around the use case of two Claude Code sessions |
| 5 | running on separate machines and needing to hand work to each other without |
| 6 | either one having to drive the other interactively. |
| 7 | |
| 8 | The transport is a shared filesystem (NFS, SMB, anything mounted on both |
| 9 | sides). The integrity story is HMAC-SHA256 signing of the task envelope. |
| 10 | The execution story is a per-side watcher that polls the inbox, verifies |
| 11 | the signature, optionally waits for a human ack, then spawns a headless |
| 12 | agent worker. Results land back as JSON files on the originating side. |
| 13 | |
| 14 | There are no public-facing ports, no broker process, and no network code |
| 15 | beyond an optional outbound Discord webhook for human notifications. |
| 16 | |
| 17 | ## Why this exists |
| 18 | |
| 19 | If you run agent sessions on more than one machine, you eventually want |
| 20 | them to be able to delegate work to each other. A Claude Code session on |
| 21 | your dev box can hand a "go pull this repo and run the test suite on the |
| 22 | GPU box" job to a peer session running on the GPU box, get back the result |
| 23 | as a structured file, and continue. The two sessions never need each |
| 24 | other's terminals open or attached. |
| 25 | |
| 26 | The pieces this needs: |
| 27 | |
| 28 | - A shared place to drop signed task envelopes |
| 29 | - A way to verify the sender is who they say |
| 30 | - A bounded executor on the receiving side that can't run away |
| 31 | - A way to halt the whole thing instantly when something is wrong |
| 32 | - An audit log |
| 33 | |
| 34 | That's the whole project. |
| 35 | |
| 36 | ## Architecture |
| 37 | |
| 38 | ``` |
| 39 | node A node B |
| 40 | +---------+ +---------+ |
| 41 | | agent | | agent | |
| 42 | | session | | session | |
| 43 | +----+----+ +----+----+ |
| 44 | | | |
| 45 | | dispatch-send --to b --request "..." | |
| 46 | v v |
| 47 | +---------+ shared filesystem (NFS, SMB, etc.) +---------+ |
| 48 | | inbox |<------------------------------------- >| inbox | |
| 49 | +---------+ a-to-b/ , b-to-a/ +---------+ |
| 50 | ^ ^ |
| 51 | | | |
| 52 | +---------+ +---------+ |
| 53 | | watcher | verify HMAC, gate on ack, spawn exec | watcher | |
| 54 | +----+----+ +----+----+ |
| 55 | | | |
| 56 | v v |
| 57 | +---------+ +---------+ |
| 58 | | exec | spawn headless agent, capture, log | exec | |
| 59 | +----+----+ +----+----+ |
| 60 | | | |
| 61 | +--> done/<id>.result.json + done/<id>.log <---------+ |
| 62 | ``` |
| 63 | |
| 64 | ## Components |
| 65 | |
| 66 | ``` |
| 67 | bin/ |
| 68 | dispatch_lib.py shared helpers: HMAC sign/verify, task envelope, |
| 69 | lane resolution, killswitch check, append-only log |
| 70 | dispatch_watcher.py per-side polling loop: verifies inbox, promotes |
| 71 | to pending-ack or processing, spawns exec with a |
| 72 | concurrency cap, cleans up markers |
| 73 | dispatch-send CLI: enqueue a signed task to the other side's inbox |
| 74 | dispatch-exec headless executor: reads a task, spawns the |
| 75 | configured agent binary, captures, writes done/ |
| 76 | dispatch-ack CLI: approve a pending-ack task |
| 77 | dispatch-watch-a wrapper that runs the watcher with --side a |
| 78 | dispatch-watch-b wrapper that runs the watcher with --side b |
| 79 | ``` |
| 80 | |
| 81 | ## Task envelope (v2) |
| 82 | |
| 83 | ```json |
| 84 | { |
| 85 | "id": "<uuid>", |
| 86 | "from": "<node-id>", |
| 87 | "to": "<node-id>", |
| 88 | "created": "<ISO-8601 UTC>", |
| 89 | "priority": "low | normal | high", |
| 90 | "request": "<free text>", |
| 91 | "require_ack": false, |
| 92 | "require_dangerous": false, |
| 93 | "timeout_s": 600, |
| 94 | "max_output_bytes": 2000000, |
| 95 | "schema": 2, |
| 96 | "hmac": "<hex sha256 hmac>" |
| 97 | } |
| 98 | ``` |
| 99 | |
| 100 | The HMAC covers `id | from | to | created | priority | request`. |
| 101 | Tampering with any of those invalidates the signature; the watcher moves |
| 102 | bad-HMAC tasks to `rejected/` and logs. |
| 103 | |
| 104 | `require_ack=true` parks the task in `pending-ack/` until a human or an |
| 105 | automation drops an `<id>.ack` file next to it. `require_dangerous=true` |
| 106 | runs the executor in `bypassPermissions` mode; default is `acceptEdits`. |
| 107 | |
| 108 | ## Directory layout under `$DISPATCH_ROOT` |
| 109 | |
| 110 | ``` |
| 111 | $DISPATCH_ROOT/ |
| 112 | keys/hmac.key shared secret (chmod 600) |
| 113 | KILLSWITCH if this file exists, no new exec spawns |
| 114 | session-log.jsonl append-only event log |
| 115 | heartbeats/<node>.json last-write liveness for each side |
| 116 | a-to-b/ |
| 117 | inbox/ pending-ack/ processing/ done/ rejected/ |
| 118 | b-to-a/ |
| 119 | inbox/ pending-ack/ processing/ done/ rejected/ |
| 120 | ``` |
| 121 | |
| 122 | ## Setup |
| 123 | |
| 124 | ```bash |
| 125 | export DISPATCH_ROOT=/mnt/shared/dispatch |
| 126 | export DISPATCH_NODE_A=devbox |
| 127 | export DISPATCH_NODE_B=gpubox |
| 128 | |
| 129 | mkdir -p "$DISPATCH_ROOT/keys" |
| 130 | openssl rand -hex 32 > "$DISPATCH_ROOT/keys/hmac.key" |
| 131 | chmod 600 "$DISPATCH_ROOT/keys/hmac.key" |
| 132 | |
| 133 | mkdir -p "$DISPATCH_ROOT/${DISPATCH_NODE_A}-to-${DISPATCH_NODE_B}"/{inbox,pending-ack,processing,done,rejected} |
| 134 | mkdir -p "$DISPATCH_ROOT/${DISPATCH_NODE_B}-to-${DISPATCH_NODE_A}"/{inbox,pending-ack,processing,done,rejected} |
| 135 | mkdir -p "$DISPATCH_ROOT/heartbeats" |
| 136 | ``` |
| 137 | |
| 138 | On node A: |
| 139 | |
| 140 | ```bash |
| 141 | DISPATCH_SIDE=devbox bin/dispatch-watch-a |
| 142 | ``` |
| 143 | |
| 144 | On node B: |
| 145 | |
| 146 | ```bash |
| 147 | DISPATCH_SIDE=gpubox bin/dispatch-watch-b |
| 148 | ``` |
| 149 | |
| 150 | Both watchers should run under systemd (or your supervisor of choice) for |
| 151 | liveness. |
| 152 | |
| 153 | ## Usage |
| 154 | |
| 155 | Send a task from A to B: |
| 156 | |
| 157 | ```bash |
| 158 | DISPATCH_FROM=devbox bin/dispatch-send \ |
| 159 | --to gpubox \ |
| 160 | --request "Run the smoke test suite on this branch and report numbers." |
| 161 | ``` |
| 162 | |
| 163 | `dispatch-send` prints the task id and the inbox path. The watcher on B |
| 164 | verifies the HMAC, decides whether to require an ack, and (if auto-exec) |
| 165 | spawns the executor. |
| 166 | |
| 167 | The result lands at: |
| 168 | |
| 169 | ``` |
| 170 | $DISPATCH_ROOT/devbox-to-gpubox/done/<id>.result.json |
| 171 | $DISPATCH_ROOT/devbox-to-gpubox/done/<id>.log |
| 172 | ``` |
| 173 | |
| 174 | ## Killswitch |
| 175 | |
| 176 | ```bash |
| 177 | echo "stopping for reason X" > "$DISPATCH_ROOT/KILLSWITCH" |
| 178 | ``` |
| 179 | |
| 180 | Both watchers refuse to spawn new exec processes while the file exists. |
| 181 | Running exec processes are not interrupted; they finish or hit their |
| 182 | timeout. Remove the file to resume. |
| 183 | |
| 184 | ## Knobs |
| 185 | |
| 186 | Environment variables read by the watcher and exec: |
| 187 | |
| 188 | | Var | Default | Meaning | |
| 189 | | ------------------------- | ------- | -------------------------------------- | |
| 190 | | `DISPATCH_ROOT` | - | required, shared filesystem path | |
| 191 | | `DISPATCH_NODE_A` | `a` | node A identifier | |
| 192 | | `DISPATCH_NODE_B` | `b` | node B identifier | |
| 193 | | `DISPATCH_POLL_SEC` | `3` | watcher poll interval | |
| 194 | | `DISPATCH_MAX_PARALLEL` | `2` | concurrent exec processes per side | |
| 195 | | `DISPATCH_AGENT_BIN` | `claude` | executor command | |
| 196 | | `DISPATCH_FROM` | - | default sender id for `dispatch-send` | |
| 197 | | `DISPATCH_SIDE` | - | this node's id (for `dispatch-ack`) | |
| 198 | |
| 199 | ## License |
| 200 | |
| 201 | MIT. See [LICENSE](LICENSE). |