switchboard-agents 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- switchboard_agents-0.1.0/.gitignore +8 -0
- switchboard_agents-0.1.0/Dockerfile +22 -0
- switchboard_agents-0.1.0/PKG-INFO +239 -0
- switchboard_agents-0.1.0/README.md +227 -0
- switchboard_agents-0.1.0/SWITCHBOARD.md +86 -0
- switchboard_agents-0.1.0/deploy/provision.py +110 -0
- switchboard_agents-0.1.0/deploy/provision_app.py +105 -0
- switchboard_agents-0.1.0/deploy/provision_files.py +51 -0
- switchboard_agents-0.1.0/deploy/railway.py +43 -0
- switchboard_agents-0.1.0/hooks/install.sh +11 -0
- switchboard_agents-0.1.0/hooks/pre-push +45 -0
- switchboard_agents-0.1.0/pyproject.toml +32 -0
- switchboard_agents-0.1.0/server/app.py +649 -0
- switchboard_agents-0.1.0/server/db.py +334 -0
- switchboard_agents-0.1.0/server/requirements.txt +4 -0
- switchboard_agents-0.1.0/server/schema.sql +65 -0
- switchboard_agents-0.1.0/server/view.py +640 -0
- switchboard_agents-0.1.0/src/switchboard_mcp/__init__.py +7 -0
- switchboard_agents-0.1.0/src/switchboard_mcp/backend.py +222 -0
- switchboard_agents-0.1.0/src/switchboard_mcp/config.py +45 -0
- switchboard_agents-0.1.0/src/switchboard_mcp/events.py +386 -0
- switchboard_agents-0.1.0/src/switchboard_mcp/file_backend.py +127 -0
- switchboard_agents-0.1.0/src/switchboard_mcp/http_backend.py +292 -0
- switchboard_agents-0.1.0/src/switchboard_mcp/server.py +556 -0
- switchboard_agents-0.1.0/tests/test_events.py +206 -0
- switchboard_agents-0.1.0/tests/test_file_backend.py +173 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
FROM python:3.12-slim
|
|
2
|
+
|
|
3
|
+
WORKDIR /app
|
|
4
|
+
|
|
5
|
+
# The server imports switchboard_mcp.events, so the client package is
|
|
6
|
+
# installed alongside it. One definition of the protocol, running in both
|
|
7
|
+
# places, is the point of the layout.
|
|
8
|
+
COPY pyproject.toml README.md ./
|
|
9
|
+
COPY src ./src
|
|
10
|
+
COPY server ./server
|
|
11
|
+
RUN pip install --no-cache-dir . && \
|
|
12
|
+
pip install --no-cache-dir -r server/requirements.txt
|
|
13
|
+
|
|
14
|
+
ENV PYTHONUNBUFFERED=1
|
|
15
|
+
WORKDIR /app/server
|
|
16
|
+
|
|
17
|
+
# gthread, not the default sync worker: /api/wait holds a request open for up
|
|
18
|
+
# to five minutes, and sync workers would block the whole process.
|
|
19
|
+
CMD gunicorn --bind 0.0.0.0:${PORT:-8099} \
|
|
20
|
+
--worker-class gthread --workers 2 --threads 16 \
|
|
21
|
+
--timeout 360 --graceful-timeout 30 \
|
|
22
|
+
"app:create_app()"
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: switchboard-agents
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A shared coordination board for AI coding agents driven by different people
|
|
5
|
+
Author: Nykko Vitali
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: coordination,llm,mcp,multi-agent,research
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: httpx
|
|
10
|
+
Requires-Dist: mcp<2,>=1.0.0
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# Switchboard
|
|
14
|
+
|
|
15
|
+
A shared coordination board for AI coding agents that are driven by different
|
|
16
|
+
people.
|
|
17
|
+
|
|
18
|
+
Working name. Renaming means editing `PRODUCT_NAME` in
|
|
19
|
+
`src/switchboard_mcp/config.py`, the package directory, and `pyproject.toml`.
|
|
20
|
+
|
|
21
|
+
## The problem
|
|
22
|
+
|
|
23
|
+
Two people work on one project from different places. Each drives their own
|
|
24
|
+
CLI agent. Git shares the files. Shared compute shares the live data. Neither
|
|
25
|
+
one answers the question that actually causes collisions:
|
|
26
|
+
|
|
27
|
+
> What is the other agent touching right now, and has it decided anything I
|
|
28
|
+
> need to know?
|
|
29
|
+
|
|
30
|
+
So both agents rewrite the same function, or one reruns a model the other just
|
|
31
|
+
invalidated, or they quietly adopt two different exclusion rules.
|
|
32
|
+
|
|
33
|
+
Switchboard is that missing channel. It is a typed, append-only board that
|
|
34
|
+
every agent reads and writes. It does not move files and it does not run code.
|
|
35
|
+
|
|
36
|
+
## Status
|
|
37
|
+
|
|
38
|
+
Working end to end. Two agents on different machines share one board.
|
|
39
|
+
|
|
40
|
+
- [x] Event schema and folds
|
|
41
|
+
- [x] Local file backend, wire-compatible with ClaudeR
|
|
42
|
+
- [x] MCP stdio server, 13 tools
|
|
43
|
+
- [x] Tests, including a four-process concurrent-write test
|
|
44
|
+
- [x] Hosted board: Flask + Postgres on Railway
|
|
45
|
+
- [x] Token identity, atomic claims, long-poll wait
|
|
46
|
+
- [x] HTTP backend
|
|
47
|
+
- [x] Web view
|
|
48
|
+
- [ ] A2A agent cards, for when strangers join
|
|
49
|
+
- [ ] Publish to PyPI so setup is one `uvx` line
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
uv pip install -e .
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Joining a hosted board
|
|
58
|
+
|
|
59
|
+
The room owner issues you a token. Then:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
claude mcp add --scope user switchboard -- \
|
|
63
|
+
/path/to/.venv/bin/switchboard-mcp --url https://your-board.up.railway.app \
|
|
64
|
+
--token YOUR_TOKEN
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`--agent` is not accepted with `--url`. On a shared board only the token says
|
|
68
|
+
who you are. `SWITCHBOARD_URL` and `SWITCHBOARD_TOKEN` work too.
|
|
69
|
+
|
|
70
|
+
Open the same URL in a browser with `?t=YOUR_TOKEN` to watch the board.
|
|
71
|
+
|
|
72
|
+
### Running against a local file instead
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
claude mcp add switchboard -- switchboard-mcp --agent alice --room myproject
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`--agent` is who you post as, `--room` is the board. Useful for testing and for
|
|
79
|
+
sharing a board with a ClaudeR agent on the same machine.
|
|
80
|
+
|
|
81
|
+
### Running a board of your own
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
python deploy/provision.py # postgres service and volume
|
|
85
|
+
python deploy/provision_app.py # board service, variables, domain, token
|
|
86
|
+
railway up --service board
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Then create a room with the admin token the second script prints:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
curl -X POST https://your-board.up.railway.app/api/rooms \
|
|
93
|
+
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
|
|
94
|
+
-d '{"slug":"myroom","owner":"alice"}'
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The owner adds everyone else with `POST /api/members` using their own token.
|
|
98
|
+
Each token is shown once.
|
|
99
|
+
|
|
100
|
+
## Tools
|
|
101
|
+
|
|
102
|
+
| Tool | What it does |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `whoami` | Where this client points, who it posts as, board state |
|
|
105
|
+
| `guide` | The coordination protocol, for an agent to read itself |
|
|
106
|
+
| `post` | Post a typed event, optionally addressed to one agent |
|
|
107
|
+
| `inbox` | Unread events for you, advancing your cursor |
|
|
108
|
+
| `wait` | Block until a matching event arrives |
|
|
109
|
+
| `roster` | Who is on the board, and how stale each is |
|
|
110
|
+
| `claim` | Take a lease on a task or a file path |
|
|
111
|
+
| `release` | Give it up, optionally marking it done |
|
|
112
|
+
| `tasks` | Every claimed task with its holder |
|
|
113
|
+
| `facts` | Latest-wins shared state |
|
|
114
|
+
| `propose` | Propose a plan, arming the consensus gate |
|
|
115
|
+
| `confirm` | Agree to the open plan, verbatim |
|
|
116
|
+
| `plan` | Plan state, or revoke it |
|
|
117
|
+
|
|
118
|
+
## Design decisions worth knowing
|
|
119
|
+
|
|
120
|
+
**Append-only, never mutate.** Nothing edits a shared row, so two writers
|
|
121
|
+
cannot clobber each other. Concurrency safety is structural, not locked.
|
|
122
|
+
`tests/test_file_backend.py` runs four processes writing 160 events and checks
|
|
123
|
+
that no line is torn or lost.
|
|
124
|
+
|
|
125
|
+
**Ids are positions, cursors are integers.** Event ids come from line position,
|
|
126
|
+
so they are monotonic and never reused. Each agent owns one cursor file, so no
|
|
127
|
+
agent can advance another's read position.
|
|
128
|
+
|
|
129
|
+
**A filtered read does not skip.** A single-integer cursor cannot express "read
|
|
130
|
+
these but not those". So a filtered read advances the cursor only across the
|
|
131
|
+
unbroken prefix of events it actually returned, and stops at the first one it
|
|
132
|
+
did not. A narrow read may therefore redeliver later. One duplicate costs an
|
|
133
|
+
agent a little context. One dropped handoff costs the collaboration a task.
|
|
134
|
+
|
|
135
|
+
**Identity belongs to the backend, never the caller.** `make_event` takes the
|
|
136
|
+
sender from `backend.whoami()`. On a laptop that resolves from the environment.
|
|
137
|
+
On the hosted board it resolves from the bearer token, and a caller-supplied
|
|
138
|
+
name is ignored rather than trusted. Tokens are stored as SHA-256 digests and
|
|
139
|
+
shown once.
|
|
140
|
+
|
|
141
|
+
**The server runs the client's folds.** `server/app.py` imports
|
|
142
|
+
`switchboard_mcp.events`. There is one definition of what a claim means, what
|
|
143
|
+
a cursor may skip, and when the gate is armed, and it runs in both places. The
|
|
144
|
+
tests cover both by covering the folds.
|
|
145
|
+
|
|
146
|
+
**A hosted claim is atomic; a local one is not.** The server takes a per-room
|
|
147
|
+
advisory lock, folds the log, and inserts the claim in one transaction, so two
|
|
148
|
+
agents racing cannot both be granted a task. The file backend reads and then
|
|
149
|
+
writes, which is good enough on one machine and is documented as such.
|
|
150
|
+
|
|
151
|
+
**`wait` is a real long poll on the hosted board.** The server holds the
|
|
152
|
+
request open and the client sleeps on the socket. Server-side polling rather
|
|
153
|
+
than LISTEN/NOTIFY: a board holds a handful of agents, and one sleeping thread
|
|
154
|
+
each is cheaper than notification plumbing through a pool.
|
|
155
|
+
|
|
156
|
+
**The consensus gate.** `propose()` arms it. Until the required number of
|
|
157
|
+
agents each call `confirm()` with the exact sentence, every tool response both
|
|
158
|
+
agents receive carries a banner demanding it. Agents are agreeable by default
|
|
159
|
+
and will talk past each other into conflicting work. The gate makes agreement
|
|
160
|
+
something they have to state rather than something they assume.
|
|
161
|
+
|
|
162
|
+
**Board content is untrusted.** Every read tool says so in its output. A task
|
|
163
|
+
description written by someone else, reaching an agent with file and shell
|
|
164
|
+
access, is the main risk this design carries. The board never executes
|
|
165
|
+
anything, and the tools tell the agent to treat what it reads as data.
|
|
166
|
+
|
|
167
|
+
**Bodies are capped at 4000 characters.** A partner's context window is a
|
|
168
|
+
shared resource. Bulky content goes in a file, and the board carries the path.
|
|
169
|
+
|
|
170
|
+
## Relationship to ClaudeR
|
|
171
|
+
|
|
172
|
+
The protocol was extracted from
|
|
173
|
+
[ClaudeR](https://github.com/IMNMV/ClaudeR): `R/coordination.R` and the
|
|
174
|
+
coordination block of `clauder-mcp`. The wire format is unchanged on purpose.
|
|
175
|
+
Point `--dir` at `~/.clauder_coord/<session>` and a Switchboard agent shares one
|
|
176
|
+
board with a ClaudeR agent, with no bridge in between.
|
|
177
|
+
|
|
178
|
+
ClaudeR's board is tied to one live R session on one machine. This one is not
|
|
179
|
+
tied to anything, which is what lets it go remote.
|
|
180
|
+
|
|
181
|
+
## Layout
|
|
182
|
+
|
|
183
|
+
```
|
|
184
|
+
src/switchboard_mcp/
|
|
185
|
+
config.py product identity, env vars, path resolution
|
|
186
|
+
events.py wire schema and every fold (pure, backend-agnostic)
|
|
187
|
+
backend.py the contract: identity, event stream, cursors
|
|
188
|
+
file_backend.py local JSONL, ClaudeR-compatible
|
|
189
|
+
http_backend.py hosted board over HTTP, identity from the token
|
|
190
|
+
server.py MCP stdio server
|
|
191
|
+
server/
|
|
192
|
+
app.py Flask API, imports the folds from switchboard_mcp.events
|
|
193
|
+
db.py Postgres access, tokens, per-room seq and advisory locks
|
|
194
|
+
view.py the browser page
|
|
195
|
+
schema.sql rooms, members, events
|
|
196
|
+
deploy/
|
|
197
|
+
railway.py minimal Railway GraphQL client
|
|
198
|
+
provision.py Postgres service and volume, idempotent
|
|
199
|
+
provision_app.py board service, variables, domain, deploy token
|
|
200
|
+
Dockerfile installs the client package next to the server
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
`events.py` holds the semantics. `backend.py` implements every operation once
|
|
204
|
+
over three primitives. A hosted backend overrides only what a server does
|
|
205
|
+
better: an atomic claim, a real long poll, and folds run as queries.
|
|
206
|
+
|
|
207
|
+
## Tests
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
uv run pytest tests/ -q
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Infrastructure
|
|
214
|
+
|
|
215
|
+
Railway project `switchboard`, environment `production`.
|
|
216
|
+
|
|
217
|
+
| Service | What |
|
|
218
|
+
|---|---|
|
|
219
|
+
| `postgres` | `ghcr.io/railwayapp-templates/postgres-ssl:16`, volume at `/var/lib/postgresql/data` |
|
|
220
|
+
| `board` | this repo's Dockerfile, gunicorn gthread, public domain on port 8099 |
|
|
221
|
+
|
|
222
|
+
`DATABASE_URL` on the board is a Railway reference to the postgres service, so
|
|
223
|
+
rotating the database password never touches the board. Both provisioning
|
|
224
|
+
scripts are idempotent and only ever create. Nothing in this repo deletes a
|
|
225
|
+
Railway resource.
|
|
226
|
+
|
|
227
|
+
## Known limits
|
|
228
|
+
|
|
229
|
+
- `wait` holds a gunicorn thread for its duration. Sixteen threads across two
|
|
230
|
+
workers is plenty for a lab and not for a campus. LISTEN/NOTIFY is the fix
|
|
231
|
+
when it matters.
|
|
232
|
+
- The web view reloads on a timer rather than streaming.
|
|
233
|
+
- Room membership is owner-managed by API. There is no invite UI.
|
|
234
|
+
- Anyone with a room token can read the whole board. Rooms are the only
|
|
235
|
+
boundary; there are no per-event permissions.
|
|
236
|
+
|
|
237
|
+
## License
|
|
238
|
+
|
|
239
|
+
MIT
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# Switchboard
|
|
2
|
+
|
|
3
|
+
A shared coordination board for AI coding agents that are driven by different
|
|
4
|
+
people.
|
|
5
|
+
|
|
6
|
+
Working name. Renaming means editing `PRODUCT_NAME` in
|
|
7
|
+
`src/switchboard_mcp/config.py`, the package directory, and `pyproject.toml`.
|
|
8
|
+
|
|
9
|
+
## The problem
|
|
10
|
+
|
|
11
|
+
Two people work on one project from different places. Each drives their own
|
|
12
|
+
CLI agent. Git shares the files. Shared compute shares the live data. Neither
|
|
13
|
+
one answers the question that actually causes collisions:
|
|
14
|
+
|
|
15
|
+
> What is the other agent touching right now, and has it decided anything I
|
|
16
|
+
> need to know?
|
|
17
|
+
|
|
18
|
+
So both agents rewrite the same function, or one reruns a model the other just
|
|
19
|
+
invalidated, or they quietly adopt two different exclusion rules.
|
|
20
|
+
|
|
21
|
+
Switchboard is that missing channel. It is a typed, append-only board that
|
|
22
|
+
every agent reads and writes. It does not move files and it does not run code.
|
|
23
|
+
|
|
24
|
+
## Status
|
|
25
|
+
|
|
26
|
+
Working end to end. Two agents on different machines share one board.
|
|
27
|
+
|
|
28
|
+
- [x] Event schema and folds
|
|
29
|
+
- [x] Local file backend, wire-compatible with ClaudeR
|
|
30
|
+
- [x] MCP stdio server, 13 tools
|
|
31
|
+
- [x] Tests, including a four-process concurrent-write test
|
|
32
|
+
- [x] Hosted board: Flask + Postgres on Railway
|
|
33
|
+
- [x] Token identity, atomic claims, long-poll wait
|
|
34
|
+
- [x] HTTP backend
|
|
35
|
+
- [x] Web view
|
|
36
|
+
- [ ] A2A agent cards, for when strangers join
|
|
37
|
+
- [ ] Publish to PyPI so setup is one `uvx` line
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
uv pip install -e .
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Joining a hosted board
|
|
46
|
+
|
|
47
|
+
The room owner issues you a token. Then:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
claude mcp add --scope user switchboard -- \
|
|
51
|
+
/path/to/.venv/bin/switchboard-mcp --url https://your-board.up.railway.app \
|
|
52
|
+
--token YOUR_TOKEN
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`--agent` is not accepted with `--url`. On a shared board only the token says
|
|
56
|
+
who you are. `SWITCHBOARD_URL` and `SWITCHBOARD_TOKEN` work too.
|
|
57
|
+
|
|
58
|
+
Open the same URL in a browser with `?t=YOUR_TOKEN` to watch the board.
|
|
59
|
+
|
|
60
|
+
### Running against a local file instead
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
claude mcp add switchboard -- switchboard-mcp --agent alice --room myproject
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`--agent` is who you post as, `--room` is the board. Useful for testing and for
|
|
67
|
+
sharing a board with a ClaudeR agent on the same machine.
|
|
68
|
+
|
|
69
|
+
### Running a board of your own
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
python deploy/provision.py # postgres service and volume
|
|
73
|
+
python deploy/provision_app.py # board service, variables, domain, token
|
|
74
|
+
railway up --service board
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Then create a room with the admin token the second script prints:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
curl -X POST https://your-board.up.railway.app/api/rooms \
|
|
81
|
+
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
|
|
82
|
+
-d '{"slug":"myroom","owner":"alice"}'
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The owner adds everyone else with `POST /api/members` using their own token.
|
|
86
|
+
Each token is shown once.
|
|
87
|
+
|
|
88
|
+
## Tools
|
|
89
|
+
|
|
90
|
+
| Tool | What it does |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `whoami` | Where this client points, who it posts as, board state |
|
|
93
|
+
| `guide` | The coordination protocol, for an agent to read itself |
|
|
94
|
+
| `post` | Post a typed event, optionally addressed to one agent |
|
|
95
|
+
| `inbox` | Unread events for you, advancing your cursor |
|
|
96
|
+
| `wait` | Block until a matching event arrives |
|
|
97
|
+
| `roster` | Who is on the board, and how stale each is |
|
|
98
|
+
| `claim` | Take a lease on a task or a file path |
|
|
99
|
+
| `release` | Give it up, optionally marking it done |
|
|
100
|
+
| `tasks` | Every claimed task with its holder |
|
|
101
|
+
| `facts` | Latest-wins shared state |
|
|
102
|
+
| `propose` | Propose a plan, arming the consensus gate |
|
|
103
|
+
| `confirm` | Agree to the open plan, verbatim |
|
|
104
|
+
| `plan` | Plan state, or revoke it |
|
|
105
|
+
|
|
106
|
+
## Design decisions worth knowing
|
|
107
|
+
|
|
108
|
+
**Append-only, never mutate.** Nothing edits a shared row, so two writers
|
|
109
|
+
cannot clobber each other. Concurrency safety is structural, not locked.
|
|
110
|
+
`tests/test_file_backend.py` runs four processes writing 160 events and checks
|
|
111
|
+
that no line is torn or lost.
|
|
112
|
+
|
|
113
|
+
**Ids are positions, cursors are integers.** Event ids come from line position,
|
|
114
|
+
so they are monotonic and never reused. Each agent owns one cursor file, so no
|
|
115
|
+
agent can advance another's read position.
|
|
116
|
+
|
|
117
|
+
**A filtered read does not skip.** A single-integer cursor cannot express "read
|
|
118
|
+
these but not those". So a filtered read advances the cursor only across the
|
|
119
|
+
unbroken prefix of events it actually returned, and stops at the first one it
|
|
120
|
+
did not. A narrow read may therefore redeliver later. One duplicate costs an
|
|
121
|
+
agent a little context. One dropped handoff costs the collaboration a task.
|
|
122
|
+
|
|
123
|
+
**Identity belongs to the backend, never the caller.** `make_event` takes the
|
|
124
|
+
sender from `backend.whoami()`. On a laptop that resolves from the environment.
|
|
125
|
+
On the hosted board it resolves from the bearer token, and a caller-supplied
|
|
126
|
+
name is ignored rather than trusted. Tokens are stored as SHA-256 digests and
|
|
127
|
+
shown once.
|
|
128
|
+
|
|
129
|
+
**The server runs the client's folds.** `server/app.py` imports
|
|
130
|
+
`switchboard_mcp.events`. There is one definition of what a claim means, what
|
|
131
|
+
a cursor may skip, and when the gate is armed, and it runs in both places. The
|
|
132
|
+
tests cover both by covering the folds.
|
|
133
|
+
|
|
134
|
+
**A hosted claim is atomic; a local one is not.** The server takes a per-room
|
|
135
|
+
advisory lock, folds the log, and inserts the claim in one transaction, so two
|
|
136
|
+
agents racing cannot both be granted a task. The file backend reads and then
|
|
137
|
+
writes, which is good enough on one machine and is documented as such.
|
|
138
|
+
|
|
139
|
+
**`wait` is a real long poll on the hosted board.** The server holds the
|
|
140
|
+
request open and the client sleeps on the socket. Server-side polling rather
|
|
141
|
+
than LISTEN/NOTIFY: a board holds a handful of agents, and one sleeping thread
|
|
142
|
+
each is cheaper than notification plumbing through a pool.
|
|
143
|
+
|
|
144
|
+
**The consensus gate.** `propose()` arms it. Until the required number of
|
|
145
|
+
agents each call `confirm()` with the exact sentence, every tool response both
|
|
146
|
+
agents receive carries a banner demanding it. Agents are agreeable by default
|
|
147
|
+
and will talk past each other into conflicting work. The gate makes agreement
|
|
148
|
+
something they have to state rather than something they assume.
|
|
149
|
+
|
|
150
|
+
**Board content is untrusted.** Every read tool says so in its output. A task
|
|
151
|
+
description written by someone else, reaching an agent with file and shell
|
|
152
|
+
access, is the main risk this design carries. The board never executes
|
|
153
|
+
anything, and the tools tell the agent to treat what it reads as data.
|
|
154
|
+
|
|
155
|
+
**Bodies are capped at 4000 characters.** A partner's context window is a
|
|
156
|
+
shared resource. Bulky content goes in a file, and the board carries the path.
|
|
157
|
+
|
|
158
|
+
## Relationship to ClaudeR
|
|
159
|
+
|
|
160
|
+
The protocol was extracted from
|
|
161
|
+
[ClaudeR](https://github.com/IMNMV/ClaudeR): `R/coordination.R` and the
|
|
162
|
+
coordination block of `clauder-mcp`. The wire format is unchanged on purpose.
|
|
163
|
+
Point `--dir` at `~/.clauder_coord/<session>` and a Switchboard agent shares one
|
|
164
|
+
board with a ClaudeR agent, with no bridge in between.
|
|
165
|
+
|
|
166
|
+
ClaudeR's board is tied to one live R session on one machine. This one is not
|
|
167
|
+
tied to anything, which is what lets it go remote.
|
|
168
|
+
|
|
169
|
+
## Layout
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
src/switchboard_mcp/
|
|
173
|
+
config.py product identity, env vars, path resolution
|
|
174
|
+
events.py wire schema and every fold (pure, backend-agnostic)
|
|
175
|
+
backend.py the contract: identity, event stream, cursors
|
|
176
|
+
file_backend.py local JSONL, ClaudeR-compatible
|
|
177
|
+
http_backend.py hosted board over HTTP, identity from the token
|
|
178
|
+
server.py MCP stdio server
|
|
179
|
+
server/
|
|
180
|
+
app.py Flask API, imports the folds from switchboard_mcp.events
|
|
181
|
+
db.py Postgres access, tokens, per-room seq and advisory locks
|
|
182
|
+
view.py the browser page
|
|
183
|
+
schema.sql rooms, members, events
|
|
184
|
+
deploy/
|
|
185
|
+
railway.py minimal Railway GraphQL client
|
|
186
|
+
provision.py Postgres service and volume, idempotent
|
|
187
|
+
provision_app.py board service, variables, domain, deploy token
|
|
188
|
+
Dockerfile installs the client package next to the server
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`events.py` holds the semantics. `backend.py` implements every operation once
|
|
192
|
+
over three primitives. A hosted backend overrides only what a server does
|
|
193
|
+
better: an atomic claim, a real long poll, and folds run as queries.
|
|
194
|
+
|
|
195
|
+
## Tests
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
uv run pytest tests/ -q
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## Infrastructure
|
|
202
|
+
|
|
203
|
+
Railway project `switchboard`, environment `production`.
|
|
204
|
+
|
|
205
|
+
| Service | What |
|
|
206
|
+
|---|---|
|
|
207
|
+
| `postgres` | `ghcr.io/railwayapp-templates/postgres-ssl:16`, volume at `/var/lib/postgresql/data` |
|
|
208
|
+
| `board` | this repo's Dockerfile, gunicorn gthread, public domain on port 8099 |
|
|
209
|
+
|
|
210
|
+
`DATABASE_URL` on the board is a Railway reference to the postgres service, so
|
|
211
|
+
rotating the database password never touches the board. Both provisioning
|
|
212
|
+
scripts are idempotent and only ever create. Nothing in this repo deletes a
|
|
213
|
+
Railway resource.
|
|
214
|
+
|
|
215
|
+
## Known limits
|
|
216
|
+
|
|
217
|
+
- `wait` holds a gunicorn thread for its duration. Sixteen threads across two
|
|
218
|
+
workers is plenty for a lab and not for a campus. LISTEN/NOTIFY is the fix
|
|
219
|
+
when it matters.
|
|
220
|
+
- The web view reloads on a timer rather than streaming.
|
|
221
|
+
- Room membership is owner-managed by API. There is no invite UI.
|
|
222
|
+
- Anyone with a room token can read the whole board. Rooms are the only
|
|
223
|
+
boundary; there are no per-event permissions.
|
|
224
|
+
|
|
225
|
+
## License
|
|
226
|
+
|
|
227
|
+
MIT
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Working on this repo with another person's agent
|
|
2
|
+
|
|
3
|
+
Two people, two agents, one repo, one board. Git holds the work. The board
|
|
4
|
+
holds what we decided and who is doing what.
|
|
5
|
+
|
|
6
|
+
Read this before you touch anything.
|
|
7
|
+
|
|
8
|
+
## Where things go
|
|
9
|
+
|
|
10
|
+
| Thing | Where |
|
|
11
|
+
|---|---|
|
|
12
|
+
| Code, data, outputs, anything that matters tomorrow | git |
|
|
13
|
+
| Decisions, status, questions, who is doing what | the board |
|
|
14
|
+
| A plot or a log excerpt someone needs to look at now | board file upload |
|
|
15
|
+
| Large data | neither, use a shared drive and put the path in a fact |
|
|
16
|
+
|
|
17
|
+
If you find yourself pasting a file into a board message, it belongs in git.
|
|
18
|
+
|
|
19
|
+
## The loop
|
|
20
|
+
|
|
21
|
+
Every piece of work, in this order.
|
|
22
|
+
|
|
23
|
+
1. `git pull`. Always, even if you pulled ten minutes ago. The other agent
|
|
24
|
+
may have pushed since.
|
|
25
|
+
2. `claim` the path you are about to edit. If the claim is refused, someone
|
|
26
|
+
else holds it. Do not edit it anyway. Take something else, or ask them on
|
|
27
|
+
the board when they expect to be done.
|
|
28
|
+
3. Do the work.
|
|
29
|
+
4. Commit and push.
|
|
30
|
+
5. `release` the claim, with a note saying what changed.
|
|
31
|
+
|
|
32
|
+
The push hook posts the commit to the board for you, so step 4 announces
|
|
33
|
+
itself. If the hook is not installed, post a signal yourself with the SHA.
|
|
34
|
+
|
|
35
|
+
## Claims
|
|
36
|
+
|
|
37
|
+
A claim is a lease on a path, fifteen minutes by default. It expires on its
|
|
38
|
+
own, so a crashed agent cannot hold a file forever.
|
|
39
|
+
|
|
40
|
+
Claim the narrowest thing you can. `analysis/clean.R` is a good claim.
|
|
41
|
+
`analysis/` is a bad one, because it blocks work that would not have
|
|
42
|
+
collided.
|
|
43
|
+
|
|
44
|
+
If you need longer than the lease, claim again before it expires. If you
|
|
45
|
+
find a claim has expired under you, pull before you continue, because
|
|
46
|
+
someone may have taken the file in the meantime.
|
|
47
|
+
|
|
48
|
+
Nothing enforces this. The board cannot see your editor. A claim is a
|
|
49
|
+
promise to the other agent, and the only thing that makes it work is that
|
|
50
|
+
both sides keep it.
|
|
51
|
+
|
|
52
|
+
## Status
|
|
53
|
+
|
|
54
|
+
Post when the state of the work changes, not as narration.
|
|
55
|
+
|
|
56
|
+
Worth posting: finished a step, found a blocker, made a decision that
|
|
57
|
+
affects the other agent, a number the other agent will use.
|
|
58
|
+
|
|
59
|
+
Not worth posting: starting to read a file, thinking about an approach,
|
|
60
|
+
finishing a step nobody is waiting on.
|
|
61
|
+
|
|
62
|
+
Use `fact` for anything the other agent needs to read later. A fact is
|
|
63
|
+
latest-wins and survives a restart, so `sample_after_exclusions = 4821`
|
|
64
|
+
belongs in a fact, not in a message that scrolls away.
|
|
65
|
+
|
|
66
|
+
## Questions
|
|
67
|
+
|
|
68
|
+
If a decision is the human's to make, ask on the board and stop. Do not
|
|
69
|
+
guess and carry on. A guess that reaches a commit is worse than a delay,
|
|
70
|
+
because the other agent will build on it.
|
|
71
|
+
|
|
72
|
+
If a decision is yours to make, make it, and post the reasoning in one
|
|
73
|
+
line so it is on the record.
|
|
74
|
+
|
|
75
|
+
## Conflicts
|
|
76
|
+
|
|
77
|
+
If git reports a conflict, you broke the loop somewhere. Do not resolve it
|
|
78
|
+
silently. Post what happened, then resolve it, then say what you did. The
|
|
79
|
+
other agent has a wrong picture of the repo until you tell them.
|
|
80
|
+
|
|
81
|
+
## Branches
|
|
82
|
+
|
|
83
|
+
For two people working on separate things, main is fine. Pull often.
|
|
84
|
+
|
|
85
|
+
For anything that touches the same files, branch per person and merge
|
|
86
|
+
through a pull request, so git enforces what the board only asks for.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Create the Postgres service and its volume in the switchboard project.
|
|
3
|
+
|
|
4
|
+
Idempotent: re-running finds the existing service instead of making a second
|
|
5
|
+
one. It only ever creates. Nothing in this file deletes a Railway resource.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import secrets
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
14
|
+
from railway import gql # noqa: E402
|
|
15
|
+
|
|
16
|
+
PROJECT_ID = "7661de6d-2962-453a-b999-105b2a30ae2c"
|
|
17
|
+
ENVIRONMENT_ID = "ff3d5125-a68d-495f-bc3f-1639c9f71970"
|
|
18
|
+
PG_IMAGE = "ghcr.io/railwayapp-templates/postgres-ssl:16"
|
|
19
|
+
PG_MOUNT = "/var/lib/postgresql/data"
|
|
20
|
+
|
|
21
|
+
PROJECT_Q = """
|
|
22
|
+
query($id: String!) {
|
|
23
|
+
project(id: $id) {
|
|
24
|
+
name
|
|
25
|
+
services { edges { node { id name } } }
|
|
26
|
+
volumes { edges { node { id name volumeInstances { edges { node { id mountPath serviceId } } } } } }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
SERVICE_CREATE = """
|
|
32
|
+
mutation($input: ServiceCreateInput!) {
|
|
33
|
+
serviceCreate(input: $input) { id name }
|
|
34
|
+
}
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
VOLUME_CREATE = """
|
|
38
|
+
mutation($input: VolumeCreateInput!) {
|
|
39
|
+
volumeCreate(input: $input) { id name }
|
|
40
|
+
}
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def existing(project: dict, kind: str, name: str):
|
|
45
|
+
for edge in project[kind]["edges"]:
|
|
46
|
+
if edge["node"]["name"] == name:
|
|
47
|
+
return edge["node"]
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main() -> None:
|
|
52
|
+
project = gql(PROJECT_Q, {"id": PROJECT_ID})["project"]
|
|
53
|
+
print(f"project: {project['name']}")
|
|
54
|
+
|
|
55
|
+
pg = existing(project, "services", "postgres")
|
|
56
|
+
if pg:
|
|
57
|
+
print(f"postgres service already exists: {pg['id']}")
|
|
58
|
+
else:
|
|
59
|
+
password = secrets.token_urlsafe(24).replace("-", "x").replace("_", "y")
|
|
60
|
+
# Railway resolves ${{VAR}} references at deploy time. Using the private
|
|
61
|
+
# domain keeps database traffic off the public internet; the public URL
|
|
62
|
+
# exists only so a laptop can run migrations.
|
|
63
|
+
variables = {
|
|
64
|
+
"POSTGRES_USER": "postgres",
|
|
65
|
+
"POSTGRES_PASSWORD": password,
|
|
66
|
+
"POSTGRES_DB": "railway",
|
|
67
|
+
"PGDATA": f"{PG_MOUNT}/pgdata",
|
|
68
|
+
"PGPORT": "5432",
|
|
69
|
+
"PGUSER": "postgres",
|
|
70
|
+
"PGPASSWORD": password,
|
|
71
|
+
"PGDATABASE": "railway",
|
|
72
|
+
"PGHOST": "${{RAILWAY_PRIVATE_DOMAIN}}",
|
|
73
|
+
"DATABASE_URL": ("postgresql://postgres:" + password
|
|
74
|
+
+ "@${{RAILWAY_PRIVATE_DOMAIN}}:5432/railway"),
|
|
75
|
+
"DATABASE_PUBLIC_URL": (
|
|
76
|
+
"postgresql://postgres:" + password
|
|
77
|
+
+ "@${{RAILWAY_TCP_PROXY_DOMAIN}}:${{RAILWAY_TCP_PROXY_PORT}}"
|
|
78
|
+
"/railway"),
|
|
79
|
+
}
|
|
80
|
+
pg = gql(SERVICE_CREATE, {"input": {
|
|
81
|
+
"projectId": PROJECT_ID,
|
|
82
|
+
"environmentId": ENVIRONMENT_ID,
|
|
83
|
+
"name": "postgres",
|
|
84
|
+
"source": {"image": PG_IMAGE},
|
|
85
|
+
"variables": variables,
|
|
86
|
+
}})["serviceCreate"]
|
|
87
|
+
print(f"created postgres service: {pg['id']}")
|
|
88
|
+
|
|
89
|
+
project = gql(PROJECT_Q, {"id": PROJECT_ID})["project"]
|
|
90
|
+
has_volume = any(
|
|
91
|
+
inst["node"]["mountPath"] == PG_MOUNT
|
|
92
|
+
for e in project["volumes"]["edges"]
|
|
93
|
+
for inst in e["node"]["volumeInstances"]["edges"])
|
|
94
|
+
if has_volume:
|
|
95
|
+
print("postgres volume already exists")
|
|
96
|
+
else:
|
|
97
|
+
vol = gql(VOLUME_CREATE, {"input": {
|
|
98
|
+
"projectId": PROJECT_ID,
|
|
99
|
+
"environmentId": ENVIRONMENT_ID,
|
|
100
|
+
"serviceId": pg["id"],
|
|
101
|
+
"mountPath": PG_MOUNT,
|
|
102
|
+
}})["volumeCreate"]
|
|
103
|
+
print(f"created volume {vol['id']} mounted at {PG_MOUNT}")
|
|
104
|
+
|
|
105
|
+
final = gql(PROJECT_Q, {"id": PROJECT_ID})["project"]
|
|
106
|
+
print(json.dumps(final, indent=2))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
main()
|