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.
- switchboard/__init__.py +6 -0
- switchboard/__main__.py +25 -0
- switchboard/adapters/__init__.py +45 -0
- switchboard/adapters/base.py +275 -0
- switchboard/adapters/claude.py +582 -0
- switchboard/adapters/codex.py +1996 -0
- switchboard/adapters/codex_rpc.py +449 -0
- switchboard/adapters/cursor.py +222 -0
- switchboard/adapters/devin.py +128 -0
- switchboard/adapters/remote_codex.py +281 -0
- switchboard/adapters/testagent.py +44 -0
- switchboard/broker/__init__.py +0 -0
- switchboard/broker/agents.py +1328 -0
- switchboard/broker/app.py +433 -0
- switchboard/broker/auth.py +324 -0
- switchboard/broker/catchup.py +182 -0
- switchboard/broker/commands.py +542 -0
- switchboard/broker/daemon.py +474 -0
- switchboard/broker/hosts.py +236 -0
- switchboard/broker/hub.py +222 -0
- switchboard/broker/machines.py +872 -0
- switchboard/broker/oidc.py +266 -0
- switchboard/broker/passkeys.py +379 -0
- switchboard/broker/passwords.py +165 -0
- switchboard/broker/peer.py +571 -0
- switchboard/broker/people.py +310 -0
- switchboard/broker/proc.py +398 -0
- switchboard/broker/remote.py +1615 -0
- switchboard/broker/reviews.py +267 -0
- switchboard/broker/rpc.py +644 -0
- switchboard/broker/service.py +1059 -0
- switchboard/broker/web.py +1708 -0
- switchboard/build_info.py +46 -0
- switchboard/claude_registry.py +77 -0
- switchboard/cli.py +1247 -0
- switchboard/clock.py +20 -0
- switchboard/colors.py +141 -0
- switchboard/config.py +240 -0
- switchboard/db.py +685 -0
- switchboard/delivery/__init__.py +0 -0
- switchboard/delivery/engine.py +1902 -0
- switchboard/delivery/rules.py +393 -0
- switchboard/delivery/runner.py +163 -0
- switchboard/delivery/sinks.py +113 -0
- switchboard/envelope.py +561 -0
- switchboard/guardrails.py +94 -0
- switchboard/hook/__init__.py +0 -0
- switchboard/hook/switchboard_hook.py +388 -0
- switchboard/install/__init__.py +1 -0
- switchboard/install/claude.py +149 -0
- switchboard/install/codex.py +423 -0
- switchboard/install/common.py +1017 -0
- switchboard/install/cursor.py +248 -0
- switchboard/install/devin.py +233 -0
- switchboard/mcp/__init__.py +0 -0
- switchboard/mcp/claude_inbox.py +127 -0
- switchboard/mcp/client.py +401 -0
- switchboard/mcp/codex_wake.py +137 -0
- switchboard/mcp/identity.py +72 -0
- switchboard/mcp/server.py +722 -0
- switchboard/models.py +735 -0
- switchboard/paths.py +248 -0
- switchboard/remote/__init__.py +9 -0
- switchboard/remote/config.py +563 -0
- switchboard/remote/describe.py +68 -0
- switchboard/remote/dialer.py +535 -0
- switchboard/remote/join.py +304 -0
- switchboard/remote/linkkey.py +252 -0
- switchboard/remote/pairing.py +1388 -0
- switchboard/remote/proto.py +713 -0
- switchboard/remote/satellite.py +1129 -0
- switchboard/report.py +927 -0
- switchboard/reviews.py +263 -0
- switchboard/store.py +2340 -0
- switchboard/web/static/agents/NOTICE +22 -0
- switchboard/web/static/agents/codex-dark.png +0 -0
- switchboard/web/static/agents/codex-light.png +0 -0
- switchboard/web/static/agents/cursor-dark.png +0 -0
- switchboard/web/static/agents/cursor-light.png +0 -0
- switchboard/web/static/app.js +4839 -0
- switchboard/web/static/apple-touch-icon.png +0 -0
- switchboard/web/static/diagram.js +217 -0
- switchboard/web/static/favicon-32.png +0 -0
- switchboard/web/static/favicon.svg +1 -0
- switchboard/web/static/index.html +429 -0
- switchboard/web/static/login.html +83 -0
- switchboard/web/static/login.js +107 -0
- switchboard/web/static/md.js +1096 -0
- switchboard/web/static/setup.html +112 -0
- switchboard/web/static/setup.js +185 -0
- switchboard/web/static/style.css +2617 -0
- switchboard/web/static/vendor/mermaid/LICENSE +21 -0
- switchboard/web/static/vendor/mermaid/mermaid.min.js +3636 -0
- switchboard/web/static/webauthn.js +168 -0
- switchboard_chat-0.21.1.dist-info/METADATA +141 -0
- switchboard_chat-0.21.1.dist-info/RECORD +99 -0
- switchboard_chat-0.21.1.dist-info/WHEEL +4 -0
- switchboard_chat-0.21.1.dist-info/entry_points.txt +2 -0
- switchboard_chat-0.21.1.dist-info/licenses/LICENSE +21 -0
switchboard/__init__.py
ADDED
|
@@ -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"
|
switchboard/__main__.py
ADDED
|
@@ -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")
|