switchboard-agents 0.1.0__py3-none-any.whl

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.
@@ -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,11 @@
1
+ switchboard_mcp/__init__.py,sha256=xA4Ps_7l4_lJgNXOWOEYhv-IuktmcnVwvk1q_WAC-ek,279
2
+ switchboard_mcp/backend.py,sha256=fGNnbviJXNoBLlBPqFO4rO9cFZnS_VP6T1eVy0zoo2Q,9469
3
+ switchboard_mcp/config.py,sha256=xYyaFcudYNP-UdyMoe4AD12F5X519DcGmp12V_mAJfA,1466
4
+ switchboard_mcp/events.py,sha256=RErFcDAjIByE9uCVbbpRKJjXu7m5crYXwS-4pK_NNic,14843
5
+ switchboard_mcp/file_backend.py,sha256=ZYfdEeupPDYSSWMb-nVAXG6BTl4pI5d2Di0flSFDhno,4663
6
+ switchboard_mcp/http_backend.py,sha256=_TBuhXxPATph_suFLuu0PN4Ee_HJ1C9sMo2NROb7DwI,12687
7
+ switchboard_mcp/server.py,sha256=goauCSLAFiaFari4xiK9MAKQlUprXDo5BKoYn5IR0Y8,23250
8
+ switchboard_agents-0.1.0.dist-info/METADATA,sha256=Mpzjr7JRJ6VanRMjR2rIJZuKRKgzGPXWZg7AUNHMwkc,9300
9
+ switchboard_agents-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
10
+ switchboard_agents-0.1.0.dist-info/entry_points.txt,sha256=JxtEPwWTUqny0Tw1gFqsQO-1tVPXKiMTWv9O3qwnPA4,99
11
+ switchboard_agents-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ switchboard-agents = switchboard_mcp:main
3
+ switchboard-mcp = switchboard_mcp:main
@@ -0,0 +1,7 @@
1
+ """Switchboard: a shared coordination board for AI agents driven by different people."""
2
+
3
+ from .config import PRODUCT_NAME, PRODUCT_SLUG # noqa: F401
4
+ from .server import main # noqa: F401
5
+
6
+ __version__ = "0.1.0"
7
+ __all__ = ["main", "PRODUCT_NAME", "PRODUCT_SLUG", "__version__"]
@@ -0,0 +1,222 @@
1
+ """Backend contract.
2
+
3
+ A backend owns three things and nothing else: the caller's identity, the
4
+ append-only event stream, and the per-agent read cursors. Every higher-level
5
+ operation is a default method here, written once over those primitives.
6
+
7
+ That split is the point of the abstraction. The local-file backend gets the
8
+ full protocol for free. The hosted backend overrides only the handful of
9
+ operations where a server can do better: an atomic claim, a real long poll,
10
+ and folds that should run as queries instead of downloading the log.
11
+ """
12
+
13
+ import time
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ from . import events as ev
17
+ from .config import (DEFAULT_LEASE_S, DEFAULT_STALE_AFTER_S, MAX_BODY_CHARS)
18
+
19
+
20
+ class BackendError(RuntimeError):
21
+ """Raised for conditions the agent should see as a plain refusal."""
22
+
23
+
24
+ class Backend:
25
+ # --- primitives a backend must provide ---------------------------------
26
+
27
+ def whoami(self) -> str:
28
+ raise NotImplementedError
29
+
30
+ def room(self) -> str:
31
+ raise NotImplementedError
32
+
33
+ def describe(self) -> str:
34
+ """One line telling a human where this client is pointed."""
35
+ raise NotImplementedError
36
+
37
+ def append(self, event: Dict[str, Any]) -> int:
38
+ raise NotImplementedError
39
+
40
+ def events(self) -> List[Dict[str, Any]]:
41
+ raise NotImplementedError
42
+
43
+ def cursor(self, agent: str) -> int:
44
+ raise NotImplementedError
45
+
46
+ def set_cursor(self, agent: str, through_id: int) -> None:
47
+ raise NotImplementedError
48
+
49
+ # --- operations built on those ----------------------------------------
50
+
51
+ def post(self, body: Any, to: str = "all", ev_type: str = "message",
52
+ reply_to: Optional[int] = None) -> int:
53
+ event = ev.make_event(self.whoami(), ev_type, body, to=to,
54
+ reply_to=reply_to)
55
+ import json
56
+ if len(json.dumps(event)) > MAX_BODY_CHARS:
57
+ raise BackendError(
58
+ f"Event too large (over {MAX_BODY_CHARS} characters). Write the "
59
+ "bulky content to a file and post the path instead. A partner's "
60
+ "context window is a shared resource."
61
+ )
62
+ return self.append(event)
63
+
64
+ def inbox(self, from_agent: Optional[str] = None,
65
+ ev_type: Optional[str] = None,
66
+ include_heartbeats: bool = False,
67
+ ack: bool = True) -> List[Dict[str, Any]]:
68
+ """Unread events for me, advancing my cursor only when that is safe.
69
+
70
+ The cursor is a single integer, so advancing it past a filtered read
71
+ would skip every unrelated event with a lower id and lose it for good.
72
+ Acking only the filtered slice loses those too.
73
+
74
+ So: ack only when the filtered slice reaches the same high-water mark
75
+ as everything visible, which means nothing would be stepped over. A
76
+ narrower read leaves the cursor alone and may redeliver later. Repeat
77
+ delivery costs an agent one duplicate. A dropped handoff costs the
78
+ collaboration a whole task, so the trade goes this way.
79
+ """
80
+ me = self.whoami()
81
+ all_events = self.events()
82
+ cursor = self.cursor(me)
83
+ hits = ev.unread(all_events, me, cursor, from_agent=from_agent,
84
+ ev_type=ev_type,
85
+ include_heartbeats=include_heartbeats)
86
+ if hits and ack:
87
+ visible = ev.unread(all_events, me, cursor,
88
+ include_heartbeats=include_heartbeats)
89
+ safe = ev.ack_target(visible, hits, cursor)
90
+ if safe > cursor:
91
+ self.set_cursor(me, safe)
92
+ return hits
93
+
94
+ def wait(self, timeout_s: int = 120, poll_s: float = 1.0,
95
+ from_agent: Optional[str] = None,
96
+ ev_type: Optional[str] = None) -> List[Dict[str, Any]]:
97
+ """Block until a matching event arrives or the timeout passes.
98
+
99
+ Polling here is a local-file compromise. A hosted backend overrides
100
+ this with a server long poll, which is why the signature takes a
101
+ timeout rather than a poll count.
102
+ """
103
+ deadline = time.monotonic() + timeout_s
104
+ while True:
105
+ hits = self.inbox(from_agent=from_agent, ev_type=ev_type)
106
+ if hits:
107
+ return hits
108
+ if time.monotonic() >= deadline:
109
+ return []
110
+ time.sleep(min(poll_s, max(0.0, deadline - time.monotonic())))
111
+
112
+ def ping(self) -> int:
113
+ return self.post({}, ev_type="heartbeat")
114
+
115
+ def roster(self, stale_after_s: int = DEFAULT_STALE_AFTER_S
116
+ ) -> List[Dict[str, Any]]:
117
+ return ev.roster(self.events(), stale_after_s=stale_after_s)
118
+
119
+ def set_fact(self, key: str, value: Any) -> int:
120
+ return self.post({"key": key, "value": value}, ev_type="fact")
121
+
122
+ def facts(self) -> Dict[str, Any]:
123
+ return ev.facts(self.events())
124
+
125
+ def claim(self, task: str, lease_s: int = DEFAULT_LEASE_S
126
+ ) -> Dict[str, Any]:
127
+ """Take a lease on a task, unless someone else holds a live one.
128
+
129
+ Re-claiming a task you already hold renews the lease.
130
+
131
+ The read-then-write here is not atomic on a local file. Two agents on
132
+ one machine racing for the same task is rare enough to accept, and the
133
+ log records both attempts so the loss is visible. A hosted backend
134
+ must override this with a single transaction.
135
+ """
136
+ me = self.whoami()
137
+ # max(0, ...) not max(1, ...): a zero lease is a real value meaning
138
+ # "noting I touched this, not holding it", and events.py honours it.
139
+ lease_s = max(0, min(int(lease_s), ev.MAX_LEASE_S))
140
+ current = ev.claim_state(self.events(), task)
141
+ if current and current["valid"] and current["holder"] != me:
142
+ return {"ok": False, "held_by": current["holder"],
143
+ "expires_in_s": current["expires_in_s"]}
144
+ self.post({"task": task, "lease_s": lease_s}, ev_type="claim")
145
+ return {"ok": True, "task": task, "lease_s": lease_s}
146
+
147
+ def _must_hold(self, task: str) -> None:
148
+ """Refuse to let go of something this agent does not hold.
149
+
150
+ claim_state ignores a release from a non-holder, so the fold was
151
+ already safe. Without this the caller was still told it succeeded.
152
+ """
153
+ current = ev.claim_state(self.events(), task)
154
+ if current is None:
155
+ raise BackendError(f"'{task}' is not claimed.")
156
+ if current["holder"] != self.whoami():
157
+ raise BackendError(
158
+ f"'{task}' is held by {current['holder']}, not you. Ask them "
159
+ "on the board rather than taking it.")
160
+
161
+ def release(self, task: str) -> int:
162
+ self._must_hold(task)
163
+ return self.post({"task": task}, ev_type="release")
164
+
165
+ def done(self, task: str, note: str = "") -> int:
166
+ self._must_hold(task)
167
+ return self.post({"task": task, "note": note}, ev_type="done")
168
+
169
+ def open_claims(self) -> List[Dict[str, Any]]:
170
+ return ev.open_claims(self.events())
171
+
172
+ def propose(self, text: str, required: int = 2) -> Dict[str, Any]:
173
+ required = max(1, min(int(required), ev.MAX_REQUIRED))
174
+ self.post({"text": text, "required": required}, ev_type="proposal")
175
+ return ev.consensus_state(self.events()) or {}
176
+
177
+ def confirm(self, statement: str) -> Dict[str, Any]:
178
+ if statement != ev.CONSENSUS_SENTENCE:
179
+ raise BackendError(
180
+ "Confirmation rejected. The statement must be exactly:\n"
181
+ f"'{ev.CONSENSUS_SENTENCE}'\n"
182
+ "DO NOT CONFIRM THIS IF IT IS NOT TRUE."
183
+ )
184
+ state = ev.consensus_state(self.events())
185
+ if state is None:
186
+ raise BackendError("No plan is open. Call propose() first.")
187
+ if state["approved"]:
188
+ return state
189
+ # The marker is what makes this confirmation count. A bare
190
+ # post(type="confirm") does not carry it, so the gate cannot be
191
+ # satisfied without passing the sentence check just above.
192
+ self.post({"proposal_id": state["proposal_id"],
193
+ ev.CONFIRM_MARKER: True}, ev_type="confirm")
194
+ return ev.consensus_state(self.events()) or {}
195
+
196
+ def revoke(self) -> int:
197
+ return self.post({}, ev_type="proposal_revoked")
198
+
199
+ def consensus(self) -> Optional[Dict[str, Any]]:
200
+ return ev.consensus_state(self.events())
201
+
202
+ # --- files -------------------------------------------------------------
203
+ # Attachments need somewhere shared to put the bytes, which a local log
204
+ # directory is not. Rather than half-implement it by copying files next to
205
+ # the log, the local backend says plainly that this needs a hosted board.
206
+
207
+ def upload_file(self, local_path: str, to: str = "all",
208
+ note: str = "") -> Dict[str, Any]:
209
+ raise BackendError(
210
+ "Files need a hosted board. This client is using a local log; "
211
+ "share the file through git or point at a board with --url.")
212
+
213
+ def download_file(self, file_id: str, dest: str = None) -> Dict[str, Any]:
214
+ raise BackendError(
215
+ "Files need a hosted board. This client is using a local log.")
216
+
217
+ def list_files(self) -> List[Dict[str, Any]]:
218
+ raise BackendError(
219
+ "Files need a hosted board. This client is using a local log.")
220
+
221
+ def banner_needed(self) -> bool:
222
+ return ev.banner_needed(self.events())
@@ -0,0 +1,45 @@
1
+ """Product identity and environment resolution.
2
+
3
+ Every user-visible name flows from PRODUCT_NAME, so renaming the project is a
4
+ one-line change here plus the package directory and the pyproject entries.
5
+ """
6
+
7
+ import os
8
+ import sys
9
+
10
+ PRODUCT_NAME = "Switchboard"
11
+ PRODUCT_SLUG = "switchboard"
12
+
13
+ # Env vars. Prefixed by the slug so a rename does not silently keep reading
14
+ # the old names.
15
+ ENV_PREFIX = PRODUCT_SLUG.upper()
16
+ ENV_ROOM = f"{ENV_PREFIX}_ROOM"
17
+ ENV_AGENT = f"{ENV_PREFIX}_AGENT"
18
+ ENV_DIR = f"{ENV_PREFIX}_DIR"
19
+ ENV_URL = f"{ENV_PREFIX}_URL"
20
+ ENV_TOKEN = f"{ENV_PREFIX}_TOKEN"
21
+
22
+ # A body larger than this is almost always a payload that belongs in a file.
23
+ # Carried over from ClaudeR, where it stopped agents pasting whole scripts
24
+ # into the log and drowning their partner's context window.
25
+ MAX_BODY_CHARS = 4000
26
+
27
+ DEFAULT_ROOM = "default"
28
+ DEFAULT_LEASE_S = 900
29
+ DEFAULT_STALE_AFTER_S = 900
30
+
31
+
32
+ def home_dir() -> str:
33
+ """Home directory, resolved the way the R side of ClaudeR resolves it.
34
+
35
+ On Windows R's path.expand("~") follows USERPROFILE. Matching that keeps a
36
+ ClaudeR log and a Switchboard log in the same folder instead of two.
37
+ """
38
+ if sys.platform == "win32":
39
+ return os.environ.get("USERPROFILE") or os.path.expanduser("~")
40
+ return os.path.expanduser("~")
41
+
42
+
43
+ def safe_name(name: str) -> str:
44
+ """Filesystem- and identity-safe form of a room or agent name."""
45
+ return "".join(c if (c.isalnum() or c in "_-") else "_" for c in name)