switchboard-chat 0.21.1__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.
Files changed (99) hide show
  1. switchboard/__init__.py +6 -0
  2. switchboard/__main__.py +25 -0
  3. switchboard/adapters/__init__.py +45 -0
  4. switchboard/adapters/base.py +275 -0
  5. switchboard/adapters/claude.py +582 -0
  6. switchboard/adapters/codex.py +1996 -0
  7. switchboard/adapters/codex_rpc.py +449 -0
  8. switchboard/adapters/cursor.py +222 -0
  9. switchboard/adapters/devin.py +128 -0
  10. switchboard/adapters/remote_codex.py +281 -0
  11. switchboard/adapters/testagent.py +44 -0
  12. switchboard/broker/__init__.py +0 -0
  13. switchboard/broker/agents.py +1328 -0
  14. switchboard/broker/app.py +433 -0
  15. switchboard/broker/auth.py +324 -0
  16. switchboard/broker/catchup.py +182 -0
  17. switchboard/broker/commands.py +542 -0
  18. switchboard/broker/daemon.py +474 -0
  19. switchboard/broker/hosts.py +236 -0
  20. switchboard/broker/hub.py +222 -0
  21. switchboard/broker/machines.py +872 -0
  22. switchboard/broker/oidc.py +266 -0
  23. switchboard/broker/passkeys.py +379 -0
  24. switchboard/broker/passwords.py +165 -0
  25. switchboard/broker/peer.py +571 -0
  26. switchboard/broker/people.py +310 -0
  27. switchboard/broker/proc.py +398 -0
  28. switchboard/broker/remote.py +1615 -0
  29. switchboard/broker/reviews.py +267 -0
  30. switchboard/broker/rpc.py +644 -0
  31. switchboard/broker/service.py +1059 -0
  32. switchboard/broker/web.py +1708 -0
  33. switchboard/build_info.py +46 -0
  34. switchboard/claude_registry.py +77 -0
  35. switchboard/cli.py +1247 -0
  36. switchboard/clock.py +20 -0
  37. switchboard/colors.py +141 -0
  38. switchboard/config.py +240 -0
  39. switchboard/db.py +685 -0
  40. switchboard/delivery/__init__.py +0 -0
  41. switchboard/delivery/engine.py +1902 -0
  42. switchboard/delivery/rules.py +393 -0
  43. switchboard/delivery/runner.py +163 -0
  44. switchboard/delivery/sinks.py +113 -0
  45. switchboard/envelope.py +561 -0
  46. switchboard/guardrails.py +94 -0
  47. switchboard/hook/__init__.py +0 -0
  48. switchboard/hook/switchboard_hook.py +388 -0
  49. switchboard/install/__init__.py +1 -0
  50. switchboard/install/claude.py +149 -0
  51. switchboard/install/codex.py +423 -0
  52. switchboard/install/common.py +1017 -0
  53. switchboard/install/cursor.py +248 -0
  54. switchboard/install/devin.py +233 -0
  55. switchboard/mcp/__init__.py +0 -0
  56. switchboard/mcp/claude_inbox.py +127 -0
  57. switchboard/mcp/client.py +401 -0
  58. switchboard/mcp/codex_wake.py +137 -0
  59. switchboard/mcp/identity.py +72 -0
  60. switchboard/mcp/server.py +722 -0
  61. switchboard/models.py +735 -0
  62. switchboard/paths.py +248 -0
  63. switchboard/remote/__init__.py +9 -0
  64. switchboard/remote/config.py +563 -0
  65. switchboard/remote/describe.py +68 -0
  66. switchboard/remote/dialer.py +535 -0
  67. switchboard/remote/join.py +304 -0
  68. switchboard/remote/linkkey.py +252 -0
  69. switchboard/remote/pairing.py +1388 -0
  70. switchboard/remote/proto.py +713 -0
  71. switchboard/remote/satellite.py +1129 -0
  72. switchboard/report.py +927 -0
  73. switchboard/reviews.py +263 -0
  74. switchboard/store.py +2340 -0
  75. switchboard/web/static/agents/NOTICE +22 -0
  76. switchboard/web/static/agents/codex-dark.png +0 -0
  77. switchboard/web/static/agents/codex-light.png +0 -0
  78. switchboard/web/static/agents/cursor-dark.png +0 -0
  79. switchboard/web/static/agents/cursor-light.png +0 -0
  80. switchboard/web/static/app.js +4839 -0
  81. switchboard/web/static/apple-touch-icon.png +0 -0
  82. switchboard/web/static/diagram.js +217 -0
  83. switchboard/web/static/favicon-32.png +0 -0
  84. switchboard/web/static/favicon.svg +1 -0
  85. switchboard/web/static/index.html +429 -0
  86. switchboard/web/static/login.html +83 -0
  87. switchboard/web/static/login.js +107 -0
  88. switchboard/web/static/md.js +1096 -0
  89. switchboard/web/static/setup.html +112 -0
  90. switchboard/web/static/setup.js +185 -0
  91. switchboard/web/static/style.css +2617 -0
  92. switchboard/web/static/vendor/mermaid/LICENSE +21 -0
  93. switchboard/web/static/vendor/mermaid/mermaid.min.js +3636 -0
  94. switchboard/web/static/webauthn.js +168 -0
  95. switchboard_chat-0.21.1.dist-info/METADATA +141 -0
  96. switchboard_chat-0.21.1.dist-info/RECORD +99 -0
  97. switchboard_chat-0.21.1.dist-info/WHEEL +4 -0
  98. switchboard_chat-0.21.1.dist-info/entry_points.txt +2 -0
  99. switchboard_chat-0.21.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,6 @@
1
+ """switchboard: a group chat for you and your coding agents (DESIGN.md §1, §3)."""
2
+
3
+ __version__ = "0.21.1"
4
+ # the distribution on PyPI (#233): `switchboard` there is an unrelated project. The package you
5
+ # import and the command you run are still `switchboard`.
6
+ DIST_NAME = "switchboard-chat"
@@ -0,0 +1,25 @@
1
+ """Entry point: ``python -m switchboard`` -> ``cli.main()`` (DESIGN.md §3).
2
+
3
+ For ``satellite`` on Linux, the process makes itself non-dumpable before anything
4
+ else is imported or read, so the window in which a same-user process could open its
5
+ ``/proc/<pid>/fd`` is as short as it can be (DESIGN.md §27.4.8); importing anything
6
+ first would widen that window.
7
+ """
8
+
9
+ import sys
10
+
11
+ if len(sys.argv) > 1 and sys.argv[1] == "satellite" and sys.platform.startswith("linux"):
12
+ # The far end of a remote link: non-dumpable before anything else is imported or read,
13
+ # so the window in which a same-user process could open its /proc/<pid>/fd is as short
14
+ # as it can be (DESIGN.md §27.4.8). The satellite's own harden() repeats it and reports.
15
+ import ctypes
16
+
17
+ try:
18
+ ctypes.CDLL(None, use_errno=True).prctl(4, 0, 0, 0, 0) # PR_SET_DUMPABLE, 0
19
+ except (OSError, AttributeError):
20
+ pass
21
+
22
+ from switchboard.cli import main # noqa: E402
23
+
24
+ if __name__ == "__main__":
25
+ sys.exit(main())
@@ -0,0 +1,45 @@
1
+ """Per-harness adapters (DESIGN.md §9): routing (pure) and transport (async)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from switchboard.adapters.base import Adapter, Caps, PullAdapter
6
+ from switchboard.adapters.claude import ClaudeAdapter
7
+ from switchboard.adapters.codex import CodexAdapter
8
+ from switchboard.adapters.cursor import CursorAdapter
9
+ from switchboard.adapters.devin import DevinAdapter
10
+ from switchboard.adapters.remote_codex import RemoteCodexAdapter
11
+ from switchboard.adapters.testagent import TestAgentAdapter
12
+ from switchboard.config import Config
13
+
14
+ # The adapter of a Codex session on another host (DESIGN.md §27.7); never a harness name.
15
+ REMOTE_CODEX = "codex@remote"
16
+
17
+
18
+ def build_adapters(cfg: Config) -> dict[str, Adapter]:
19
+ """Claude (M3) and Codex (M4) have push tiers; Cursor (stop park, M5) and
20
+ Devin (wait loop plus Stop re-arm, M5) are reached through their hooks. A
21
+ Codex session on a remote host is pull only (``REMOTE_CODEX``, M8c)."""
22
+ return {
23
+ "claude": ClaudeAdapter(cfg),
24
+ "codex": CodexAdapter(cfg),
25
+ REMOTE_CODEX: RemoteCodexAdapter(cfg),
26
+ "cursor": CursorAdapter(cfg),
27
+ "devin": DevinAdapter(cfg),
28
+ "unknown": PullAdapter("unknown", cfg),
29
+ "test": TestAgentAdapter(cfg),
30
+ }
31
+
32
+
33
+ __all__ = [
34
+ "REMOTE_CODEX",
35
+ "Adapter",
36
+ "Caps",
37
+ "ClaudeAdapter",
38
+ "CodexAdapter",
39
+ "CursorAdapter",
40
+ "DevinAdapter",
41
+ "PullAdapter",
42
+ "RemoteCodexAdapter",
43
+ "TestAgentAdapter",
44
+ "build_adapters",
45
+ ]
@@ -0,0 +1,275 @@
1
+ """The common adapter interface (DESIGN.md §9.1) and the M2 pull adapter.
2
+
3
+ ``route`` is pure: given a participant, what the rules released and the
4
+ member's open sink (if any), it says how the batch goes out:
5
+ ``push(path)`` (the adapter's ``send`` transports it), ``sink(id)`` (an open
6
+ wait() call returns it), ``pull`` (the next hook or tool call claims it) or
7
+ ``none(reason)`` (parked: nothing can reach the agent right now).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ from switchboard.broker.peer import McpIdentity
16
+ from switchboard.config import Config
17
+ from switchboard.hook.switchboard_hook import CONTEXT_MAX as HOOK_CONTEXT_MAX
18
+ from switchboard.models import Batch, HookEvent, Participant, Release, Route
19
+ from switchboard.models import session_key as make_session_key
20
+
21
+ INLINE_PATHS = frozenset({"inbox", "wait", "read", "say"})
22
+
23
+
24
+ def start_key(start: float | None) -> str:
25
+ """A process start time as a session-key component (DESIGN.md §6.3): fixed
26
+ precision, so a restarted process's new start never collides with the old key."""
27
+ return f"{start:.2f}" if start is not None else "?"
28
+
29
+
30
+ # Hook events whose reply may carry context, per harness (DESIGN.md §7.3,
31
+ # broker side; the hook script has its own copy of the output table).
32
+ HOOK_CONTEXT_EVENTS: dict[str, frozenset[str]] = {
33
+ "claude": frozenset({"SessionStart", "UserPromptSubmit", "PostToolUse", "PostToolUseFailure"}),
34
+ "codex": frozenset({"PostToolUse"}),
35
+ "devin": frozenset({"PostToolUse"}),
36
+ "cursor": frozenset({"PostToolUse", "PostToolUseFailure"}),
37
+ # synthetic hook events from scripted test agents (test mode only)
38
+ "test": frozenset({"UserPromptSubmit", "PostToolUse", "PostToolUseFailure"}),
39
+ }
40
+
41
+ # Events each harness registers (DESIGN.md §7.4), in canonical (Claude) spelling.
42
+ HOOK_EVENTS: dict[str, frozenset[str]] = {
43
+ "claude": frozenset(
44
+ {"SessionStart", "UserPromptSubmit", "PostToolUse", "PostToolUseFailure", "Stop", "SessionEnd"}
45
+ ),
46
+ "codex": frozenset({"UserPromptSubmit", "PostToolUse", "Stop", "Interrupt", "SessionEnd"}),
47
+ "cursor": frozenset(
48
+ {"SessionStart", "UserPromptSubmit", "PostToolUse", "PostToolUseFailure", "Stop", "SessionEnd"}
49
+ ),
50
+ "devin": frozenset(
51
+ {"SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop", "SessionEnd"}
52
+ ),
53
+ "test": frozenset(
54
+ {"SessionStart", "UserPromptSubmit", "PostToolUse", "PostToolUseFailure", "Stop", "SessionEnd"}
55
+ ),
56
+ }
57
+
58
+
59
+ class SendError(Exception):
60
+ """A push that didn't go out. The message is a fixed string (no text, no paths).
61
+
62
+ ``counted=False`` marks a re-route (the batch goes back to pending without
63
+ counting as a push failure): e.g. a Codex steer when the turn already ended."""
64
+
65
+ def __init__(self, reason: str, *, counted: bool = True):
66
+ super().__init__(reason)
67
+ self.reason = reason[:80]
68
+ self.counted = counted
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class Caps:
73
+ ctx_max_chars: int
74
+ wait_cap_s: int
75
+ inline_paths: frozenset[str] = INLINE_PATHS
76
+
77
+
78
+ class Adapter:
79
+ """Base class. Subclasses override what their harness supports."""
80
+
81
+ harness = "unknown"
82
+ # At most one unconfirmed push frame per session, across its rooms (Claude:
83
+ # a second frame would queue behind the turn the first one starts).
84
+ serial_push = False
85
+
86
+ def __init__(self, cfg: Config):
87
+ self.cfg = cfg
88
+
89
+ # -- capabilities -------------------------------------------------------
90
+ def caps(self, p: Participant | None = None) -> Caps:
91
+ c = self.cfg
92
+ table = {
93
+ "claude": (c.delivery.batch_max_chars, c.claude.wait_cap_s),
94
+ "codex": (c.codex.ctx_max_chars, c.codex.wait_cap_s),
95
+ "cursor": (c.cursor.ctx_max_chars, c.cursor.wait_cap_s),
96
+ "devin": (c.devin.ctx_max_chars, c.devin.wait_cap_s),
97
+ }
98
+ ctx, wait = table.get(self.harness, (c.delivery.batch_max_chars, 50))
99
+ # never more than the hook script prints for this harness, so its cut
100
+ # is only a safety net (it prints nothing rather than cut, §7.3)
101
+ ctx = min(ctx, HOOK_CONTEXT_MAX.get(self.harness, ctx))
102
+ return Caps(ctx_max_chars=ctx, wait_cap_s=wait)
103
+
104
+ def tier(self, p: Participant | None) -> tuple[str, str | None]:
105
+ return "mcp-only", None
106
+
107
+ # -- membership: session keys, binding (DESIGN.md §6.3) -----------------
108
+ def session_key(self, ident: McpIdentity, thread_id: str | None) -> str:
109
+ """This harness's session key for a verified ``mcp.hello`` identity: by default,
110
+ one session per agent process (claude, devin, an unknown harness). Cursor
111
+ overrides this with a pending key until its join nonce binds a conversation id."""
112
+ return make_session_key(self.harness, ident.host, f"{ident.agent_pid}@{start_key(ident.agent_start)}")
113
+
114
+ def existing_session(self, store: Any, ident: McpIdentity) -> Participant | None:
115
+ """A session this verified identity already holds under a key other than what
116
+ ``session_key`` computes right now (Cursor: by its MCP server's agent process,
117
+ until a conversation id is known) — None for harnesses whose key alone finds it."""
118
+ return None
119
+
120
+ def join_fields(self, existing: Participant | None) -> dict[str, Any]:
121
+ """Extra participant fields this harness's join needs beyond the common ones
122
+ (Cursor: a fresh or unbound row starts ``pending`` until its conversation binds
123
+ it)."""
124
+ return {}
125
+
126
+ def conversation_kicked(self, store: Any, room_id: int, p: Participant) -> bool:
127
+ """True if this session's larger identity (Cursor: its bound conversation) was
128
+ kicked from ``room_id`` under an earlier participant row, even though ``p``
129
+ itself wasn't (a resumed conversation, DESIGN.md §9.4) — False for harnesses
130
+ with no such identity wider than the participant row."""
131
+ return False
132
+
133
+ def nonce_ok(self, p: Participant, ev: HookEvent) -> bool:
134
+ """True if this hook event carries the join nonce ``p``'s own pending join
135
+ issued (Cursor's join-nonce bind) — harnesses with no such bind never have one
136
+ to check."""
137
+ return False
138
+
139
+ def bound_to(self, p: Participant, sid: str | None) -> bool:
140
+ """True if ``p`` is already bound to this hook's session id."""
141
+ return False
142
+
143
+ def conversation_key(self, p: Participant, sid: str | None) -> str | None:
144
+ """The session key a join-nonce bind to ``sid`` would set ``p`` to, or None for
145
+ a harness with no such bind, or a malformed id."""
146
+ return None
147
+
148
+ def conn_tier(self, ident: Any, existing: Participant | None) -> tuple[str, str | None]:
149
+ """The tier a join from this verified MCP connection gets (Claude: inbox if attached)."""
150
+ return self.tier(existing)
151
+
152
+ def on_joined(self, p: Participant, nonce: str, fresh: bool) -> None:
153
+ """A join (``fresh``: a new membership or another process) was persisted."""
154
+ return None
155
+
156
+ def defer_end(self, p: Participant) -> bool:
157
+ """The liveness check found ``p``'s agent process gone: True keeps the
158
+ session for now (Codex: a daemon restart's grace window), False ends it."""
159
+ return False
160
+
161
+ def on_mcp_hello(self, ident: Any, mine: list[Participant]) -> None:
162
+ """A verified ``mcp.hello`` (``mine``: the sessions of that same MCP process)."""
163
+ return None
164
+
165
+ def status_summary(self) -> str | None:
166
+ """A one-line state for ``switchboard status`` (Codex: the link), or None."""
167
+ return None
168
+
169
+ def join_guidance(self, p: Participant, room: str) -> str:
170
+ return (
171
+ "switchboard can't wake this session on its own. Call"
172
+ f' read("{room}") to check for messages, or wait("{room}", {self.caps(p).wait_cap_s})'
173
+ " to block until one arrives."
174
+ )
175
+
176
+ def context_events(self, p: Participant) -> frozenset[str]:
177
+ """Hook events whose reply may carry priority context for this participant."""
178
+ return frozenset()
179
+
180
+ def history_session_id(self, p: Participant, cfg: Config) -> tuple[str | None, str]:
181
+ """``(this harness's own session id for p, "")``, or ``(None, why not)``, for a
182
+ /catchup session-history request (DESIGN.md §26). The default: a harness
183
+ switchboard keeps no history-tool id for."""
184
+ return None, "a session of an unknown harness"
185
+
186
+ # -- routing (pure) -----------------------------------------------------
187
+ def route(self, p: Participant, rel: Release, sink: Any, now: float) -> Route:
188
+ raise NotImplementedError
189
+
190
+ def on_hook(self, p: Participant, ev: HookEvent) -> dict[str, Any] | None:
191
+ """Harness-specific side effects of a hook event (Codex: holds, thread proof)."""
192
+ return None
193
+
194
+ # -- stop-time delivery (M5: Cursor stop park, Devin Stop block) ----------
195
+ def park_s(self, p: Participant, ev: HookEvent) -> float | None:
196
+ """Seconds a stop hook may park waiting for a follow-up (Cursor), else None."""
197
+ return None
198
+
199
+ def stop_continues(self, p: Participant) -> bool:
200
+ """True if this harness's Stop hook may return a continue at once (Devin)."""
201
+ return False
202
+
203
+ def redeliver_on_stop(self, p: Participant, ev: HookEvent) -> bool:
204
+ """Stop re-delivers unanswered priority items once (§8.5) unless this says no."""
205
+ return True
206
+
207
+ def closes_waits(self, p: Participant, ev: HookEvent) -> bool:
208
+ """True if this hook means an open wait() of the session was orphaned (Devin)."""
209
+ return False
210
+
211
+ def is_wait_pre(self, ev: HookEvent) -> bool:
212
+ """A PreToolUse for switchboard's wait() whose tool_use_id pins its answer (Devin)."""
213
+ return False
214
+
215
+ def pull_confirms(self, path: str, want_tool_use: str | None, ev: HookEvent) -> bool:
216
+ """Harness rule on top of the batch token for a pull answer (Devin: only
217
+ the wait() call's own successful PostToolUse)."""
218
+ return True
219
+
220
+ def continue_verdict(
221
+ self, p: Participant, loop_count: int | None, acked: bool, ev: HookEvent
222
+ ) -> str | None:
223
+ """For an unconfirmed stop continuation: 'confirm', an expiry reason, or None."""
224
+ return None
225
+
226
+ def confirm_window_s(self) -> float:
227
+ """How long an acked stop continuation may wait for the session's next hook."""
228
+ return 180.0
229
+
230
+ def expire_due(self, p: Participant, b: Batch, now: float) -> str | None:
231
+ """Event-based expiry of an offered push batch (§8.7): a reason, or None to keep it."""
232
+ return None
233
+
234
+ def push_expired(self, p: Participant, b: Batch, reason: str, now: float) -> None:
235
+ """A push batch of this participant expired (``p.push_expiries`` already counts it)."""
236
+ return None
237
+
238
+ # -- transport ------------------------------------------------------------
239
+ async def send(self, p: Participant, batch: Batch, text: str, **meta: Any) -> float | None:
240
+ """Carry out a push route. Returns when the frame was posted (epoch s), if known."""
241
+ raise NotImplementedError(f"{self.harness} has no push transport")
242
+
243
+ async def start(self, runner: Any) -> None:
244
+ return None
245
+
246
+ async def stop(self) -> None:
247
+ return None
248
+
249
+
250
+ class PullAdapter(Adapter):
251
+ """Routing for sessions with no harness adapter (``unknown``): an open
252
+ wait() sink gets wakes; mid-task priority items are pulled by the agent's
253
+ next read()/say(); otherwise the member is parked. (Claude, Codex, Cursor
254
+ and Devin have their own adapters since M3-M5.)"""
255
+
256
+ def __init__(self, harness: str, cfg: Config):
257
+ super().__init__(cfg)
258
+ self.harness = harness
259
+
260
+ def tier(self, p: Participant | None) -> tuple[str, str | None]:
261
+ if self.harness == "codex":
262
+ # _meta.threadId is taken on trust until the M4 thread proof (§9.3)
263
+ return "mcp-only", "unverified thread"
264
+ return "mcp-only", None
265
+
266
+ def context_events(self, p: Participant) -> frozenset[str]:
267
+ # Codex, Cursor and Devin hook outputs are enabled with their adapters (M4, M5).
268
+ return frozenset()
269
+
270
+ def route(self, p: Participant, rel: Release, sink: Any, now: float) -> Route:
271
+ if sink is not None:
272
+ return Route("sink", path=sink.path, sink_id=sink.id)
273
+ if rel.kind == "priority":
274
+ return Route("pull", reason="next tool call")
275
+ return Route("none", reason="idle and not listening: call wait() or poke it")