Zion Boggan zionboggan.com ↗
201 lines · markdown
History for this file →
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).