cc-blackbox 0.2.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.
- cc_blackbox/__init__.py +101 -0
- cc_blackbox/compatibility.py +112 -0
- cc_blackbox/errors.py +66 -0
- cc_blackbox/messaging/__init__.py +21 -0
- cc_blackbox/messaging/client.py +224 -0
- cc_blackbox/messaging/protocol.py +164 -0
- cc_blackbox/messaging/transport.py +75 -0
- cc_blackbox/paths.py +125 -0
- cc_blackbox/profiles.py +284 -0
- cc_blackbox/py.typed +0 -0
- cc_blackbox/sessions/__init__.py +19 -0
- cc_blackbox/sessions/active.py +352 -0
- cc_blackbox/sessions/liveness.py +63 -0
- cc_blackbox/sessions/registry.py +54 -0
- cc_blackbox-0.2.0.dist-info/METADATA +171 -0
- cc_blackbox-0.2.0.dist-info/RECORD +18 -0
- cc_blackbox-0.2.0.dist-info/WHEEL +4 -0
- cc_blackbox-0.2.0.dist-info/licenses/LICENSE +21 -0
cc_blackbox/__init__.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Typed access to a running Claude Code (cc): profiles, active sessions and cross-session messaging.
|
|
2
|
+
|
|
3
|
+
Unofficial and not affiliated with or endorsed by Anthropic. The package relies on undocumented Claude Code internals
|
|
4
|
+
that can change in any Claude Code release.
|
|
5
|
+
|
|
6
|
+
Everything importable from this package is the public API. Submodules are implementation details.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from cc_blackbox.compatibility import (
|
|
10
|
+
SUPPORTED_PEER_PROTOCOLS,
|
|
11
|
+
TESTED_CLAUDE_CODE_VERSIONS,
|
|
12
|
+
Compatibility,
|
|
13
|
+
CompatibilityFeature,
|
|
14
|
+
check_compatibility,
|
|
15
|
+
)
|
|
16
|
+
from cc_blackbox.errors import (
|
|
17
|
+
AmbiguousSession,
|
|
18
|
+
AuthSwitchConfigError,
|
|
19
|
+
ClaudeBlackboxError,
|
|
20
|
+
MessageTooLarge,
|
|
21
|
+
MessagingError,
|
|
22
|
+
MessagingUnavailable,
|
|
23
|
+
ProfileNotFound,
|
|
24
|
+
SessionGone,
|
|
25
|
+
SessionNotFound,
|
|
26
|
+
UnsupportedProtocol,
|
|
27
|
+
)
|
|
28
|
+
from cc_blackbox.messaging import (
|
|
29
|
+
MessagingTarget,
|
|
30
|
+
Priority,
|
|
31
|
+
RateLimitedSender,
|
|
32
|
+
SentMessage,
|
|
33
|
+
build_envelope,
|
|
34
|
+
percent_encode_address,
|
|
35
|
+
rename_session,
|
|
36
|
+
send_message,
|
|
37
|
+
)
|
|
38
|
+
from cc_blackbox.paths import claude_tmp_root, project_slug, scratchpad_dir
|
|
39
|
+
from cc_blackbox.profiles import (
|
|
40
|
+
AuthSwitchConfig,
|
|
41
|
+
AuthSwitchProfile,
|
|
42
|
+
ClaudeProfile,
|
|
43
|
+
ProfileSource,
|
|
44
|
+
current_profile,
|
|
45
|
+
get_profile,
|
|
46
|
+
list_profiles,
|
|
47
|
+
read_auth_switch_config,
|
|
48
|
+
)
|
|
49
|
+
from cc_blackbox.sessions import (
|
|
50
|
+
ActiveSession,
|
|
51
|
+
SessionKind,
|
|
52
|
+
SessionStatus,
|
|
53
|
+
current_session,
|
|
54
|
+
find_session,
|
|
55
|
+
list_active_sessions,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"ActiveSession",
|
|
60
|
+
"AmbiguousSession",
|
|
61
|
+
"AuthSwitchConfig",
|
|
62
|
+
"AuthSwitchConfigError",
|
|
63
|
+
"AuthSwitchProfile",
|
|
64
|
+
"ClaudeBlackboxError",
|
|
65
|
+
"ClaudeProfile",
|
|
66
|
+
"Compatibility",
|
|
67
|
+
"CompatibilityFeature",
|
|
68
|
+
"MessageTooLarge",
|
|
69
|
+
"MessagingError",
|
|
70
|
+
"MessagingTarget",
|
|
71
|
+
"MessagingUnavailable",
|
|
72
|
+
"Priority",
|
|
73
|
+
"ProfileNotFound",
|
|
74
|
+
"ProfileSource",
|
|
75
|
+
"RateLimitedSender",
|
|
76
|
+
"SUPPORTED_PEER_PROTOCOLS",
|
|
77
|
+
"SentMessage",
|
|
78
|
+
"SessionGone",
|
|
79
|
+
"SessionKind",
|
|
80
|
+
"SessionNotFound",
|
|
81
|
+
"SessionStatus",
|
|
82
|
+
"TESTED_CLAUDE_CODE_VERSIONS",
|
|
83
|
+
"UnsupportedProtocol",
|
|
84
|
+
"build_envelope",
|
|
85
|
+
"check_compatibility",
|
|
86
|
+
"claude_tmp_root",
|
|
87
|
+
"current_profile",
|
|
88
|
+
"current_session",
|
|
89
|
+
"find_session",
|
|
90
|
+
"get_profile",
|
|
91
|
+
"list_active_sessions",
|
|
92
|
+
"list_profiles",
|
|
93
|
+
"percent_encode_address",
|
|
94
|
+
"project_slug",
|
|
95
|
+
"read_auth_switch_config",
|
|
96
|
+
"rename_session",
|
|
97
|
+
"scratchpad_dir",
|
|
98
|
+
"send_message",
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Whether a running session uses the record fields and protocol this package understands.
|
|
2
|
+
|
|
3
|
+
The check looks at what a session actually declares, never at its Claude Code version number. Claude Code updates
|
|
4
|
+
often and usually keeps these interfaces stable, so a newer Claude Code that still writes the same fields and speaks
|
|
5
|
+
the same peer protocol just works. Unknown extra record fields and unknown peer features are additions, and do not
|
|
6
|
+
make a session incompatible.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from types import MappingProxyType
|
|
15
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from cc_blackbox.sessions.active import ActiveSession
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"SUPPORTED_PEER_PROTOCOLS",
|
|
22
|
+
"TESTED_CLAUDE_CODE_VERSIONS",
|
|
23
|
+
"Compatibility",
|
|
24
|
+
"CompatibilityFeature",
|
|
25
|
+
"check_compatibility",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
SUPPORTED_PEER_PROTOCOLS: frozenset[int] = frozenset({1})
|
|
29
|
+
"""The ``peerProtocol`` values whose message frames this package can write."""
|
|
30
|
+
|
|
31
|
+
TESTED_CLAUDE_CODE_VERSIONS: tuple[str, ...] = ("2.1.319",)
|
|
32
|
+
"""The Claude Code versions the live contract test passed against. Informational only: nothing compares versions."""
|
|
33
|
+
|
|
34
|
+
CompatibilityFeature = Literal["messaging", "scratchpad", "liveness"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class Compatibility:
|
|
39
|
+
"""The result of :func:`check_compatibility`."""
|
|
40
|
+
|
|
41
|
+
compatible: bool
|
|
42
|
+
"""``True`` when every feature is aligned."""
|
|
43
|
+
features: Mapping[CompatibilityFeature, bool]
|
|
44
|
+
"""For each feature, whether the session's record provides what the feature needs."""
|
|
45
|
+
reasons: tuple[str, ...]
|
|
46
|
+
"""One sentence for each problem found, prefixed with the feature name. Empty when ``compatible`` is ``True``."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _nonempty_str(value: Any) -> bool:
|
|
50
|
+
return isinstance(value, str) and bool(value)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _int(value: Any) -> bool:
|
|
54
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _messaging(raw: Mapping[str, Any]) -> list[str]:
|
|
58
|
+
reasons = []
|
|
59
|
+
protocol = raw.get("peerProtocol")
|
|
60
|
+
supported = ", ".join(map(str, sorted(SUPPORTED_PEER_PROTOCOLS)))
|
|
61
|
+
if protocol is None:
|
|
62
|
+
reasons.append(f"messaging: the record has no peerProtocol, and this cc-blackbox version speaks {supported}.")
|
|
63
|
+
elif not _int(protocol) or protocol not in SUPPORTED_PEER_PROTOCOLS:
|
|
64
|
+
reasons.append(
|
|
65
|
+
f"messaging: the session speaks peerProtocol {protocol!r}, and this cc-blackbox version speaks "
|
|
66
|
+
f"{supported}. Upgrade cc-blackbox."
|
|
67
|
+
)
|
|
68
|
+
if not _nonempty_str(raw.get("messagingSocketPath")):
|
|
69
|
+
reasons.append("messaging: the record has no messagingSocketPath, so the session has messaging turned off.")
|
|
70
|
+
return reasons
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _scratchpad(raw: Mapping[str, Any]) -> list[str]:
|
|
74
|
+
reasons = []
|
|
75
|
+
if not _nonempty_str(raw.get("sessionId")):
|
|
76
|
+
reasons.append("scratchpad: the record has no sessionId string.")
|
|
77
|
+
cwd = raw.get("cwd")
|
|
78
|
+
if not _nonempty_str(cwd) or cwd == "?":
|
|
79
|
+
reasons.append("scratchpad: the record has no cwd string.")
|
|
80
|
+
return reasons
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _liveness(raw: Mapping[str, Any]) -> list[str]:
|
|
84
|
+
reasons = []
|
|
85
|
+
if not _int(raw.get("pid")):
|
|
86
|
+
reasons.append("liveness: the record has no integer pid.")
|
|
87
|
+
if sys.platform.startswith("linux") and not _nonempty_str(raw.get("procStart")):
|
|
88
|
+
reasons.append("liveness: the record has no procStart string, so a reused pid cannot be detected.")
|
|
89
|
+
return reasons
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def check_compatibility(session: ActiveSession) -> Compatibility:
|
|
93
|
+
"""Check whether ``session``'s record provides what each feature of this package needs.
|
|
94
|
+
|
|
95
|
+
- ``messaging`` needs a ``peerProtocol`` in :data:`SUPPORTED_PEER_PROTOCOLS` and a ``messagingSocketPath``.
|
|
96
|
+
- ``scratchpad`` needs ``sessionId`` and ``cwd`` strings.
|
|
97
|
+
- ``liveness`` needs an integer ``pid``, and on Linux a ``procStart`` string.
|
|
98
|
+
|
|
99
|
+
The Claude Code version in the record is not considered.
|
|
100
|
+
"""
|
|
101
|
+
raw = session.raw
|
|
102
|
+
checks: dict[CompatibilityFeature, list[str]] = {
|
|
103
|
+
"messaging": _messaging(raw),
|
|
104
|
+
"scratchpad": _scratchpad(raw),
|
|
105
|
+
"liveness": _liveness(raw),
|
|
106
|
+
}
|
|
107
|
+
reasons = tuple(reason for found in checks.values() for reason in found)
|
|
108
|
+
return Compatibility(
|
|
109
|
+
compatible=not reasons,
|
|
110
|
+
features=MappingProxyType({feature: not found for feature, found in checks.items()}),
|
|
111
|
+
reasons=reasons,
|
|
112
|
+
)
|
cc_blackbox/errors.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Exceptions raised by the SDK. Every one of them derives from :class:`ClaudeBlackboxError`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from cc_blackbox.sessions.active import ActiveSession
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AmbiguousSession",
|
|
13
|
+
"AuthSwitchConfigError",
|
|
14
|
+
"ClaudeBlackboxError",
|
|
15
|
+
"MessageTooLarge",
|
|
16
|
+
"MessagingError",
|
|
17
|
+
"MessagingUnavailable",
|
|
18
|
+
"ProfileNotFound",
|
|
19
|
+
"SessionGone",
|
|
20
|
+
"SessionNotFound",
|
|
21
|
+
"UnsupportedProtocol",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ClaudeBlackboxError(Exception):
|
|
26
|
+
"""Base class for every error the SDK raises."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ProfileNotFound(ClaudeBlackboxError, LookupError):
|
|
30
|
+
"""No profile has the requested name."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AuthSwitchConfigError(ClaudeBlackboxError):
|
|
34
|
+
"""The auth-switch config file exists but cannot be read as a YAML mapping."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SessionNotFound(ClaudeBlackboxError, LookupError):
|
|
38
|
+
"""No active session matches the query."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AmbiguousSession(ClaudeBlackboxError, LookupError):
|
|
42
|
+
"""More than one active session matches the query. ``candidates`` holds all of them."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, message: str, candidates: Sequence[ActiveSession]) -> None:
|
|
45
|
+
super().__init__(message)
|
|
46
|
+
self.candidates: tuple[ActiveSession, ...] = tuple(candidates)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class MessagingError(ClaudeBlackboxError):
|
|
50
|
+
"""A message or control frame could not be written to a session."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class MessagingUnavailable(MessagingError):
|
|
54
|
+
"""The session cannot receive messages: its record names no socket, or the platform is not supported."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class SessionGone(MessagingError):
|
|
58
|
+
"""Nobody listens on the session's socket, so the session has most likely ended."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class MessageTooLarge(MessagingError, ValueError):
|
|
62
|
+
"""The frame exceeds the receiver's 1 MiB line limit. Nothing was sent."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class UnsupportedProtocol(MessagingError):
|
|
66
|
+
"""The session speaks a peer protocol version this SDK does not know."""
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Claude Code's cross-session messaging: frames, transports and the sending API."""
|
|
2
|
+
|
|
3
|
+
from cc_blackbox.messaging.client import (
|
|
4
|
+
MessagingTarget,
|
|
5
|
+
RateLimitedSender,
|
|
6
|
+
SentMessage,
|
|
7
|
+
rename_session,
|
|
8
|
+
send_message,
|
|
9
|
+
)
|
|
10
|
+
from cc_blackbox.messaging.protocol import Priority, build_envelope, percent_encode_address
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"MessagingTarget",
|
|
14
|
+
"Priority",
|
|
15
|
+
"RateLimitedSender",
|
|
16
|
+
"SentMessage",
|
|
17
|
+
"build_envelope",
|
|
18
|
+
"percent_encode_address",
|
|
19
|
+
"rename_session",
|
|
20
|
+
"send_message",
|
|
21
|
+
]
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Send messages and renames to running Claude Code sessions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
from cc_blackbox.compatibility import SUPPORTED_PEER_PROTOCOLS
|
|
12
|
+
from cc_blackbox.errors import MessagingUnavailable, UnsupportedProtocol
|
|
13
|
+
from cc_blackbox.messaging.protocol import (
|
|
14
|
+
Priority,
|
|
15
|
+
build_envelope,
|
|
16
|
+
build_rename_frame,
|
|
17
|
+
build_user_frame,
|
|
18
|
+
encode_frame,
|
|
19
|
+
percent_encode_address,
|
|
20
|
+
)
|
|
21
|
+
from cc_blackbox.messaging.transport import default_transport
|
|
22
|
+
from cc_blackbox.sessions.active import ActiveSession
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"MessagingTarget",
|
|
26
|
+
"RateLimitedSender",
|
|
27
|
+
"SentMessage",
|
|
28
|
+
"rename_session",
|
|
29
|
+
"send_message",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
MessagingTarget = ActiveSession | os.PathLike[str] | str
|
|
33
|
+
"""A session from :func:`list_active_sessions`, or the path of a messaging socket."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class SentMessage:
|
|
38
|
+
"""A frame that was written to a session's socket. Writing it does not mean the model saw it."""
|
|
39
|
+
|
|
40
|
+
msg_id: str
|
|
41
|
+
socket_path: str
|
|
42
|
+
bytes_written: int
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _socket_path(target: MessagingTarget) -> str:
|
|
46
|
+
if not isinstance(target, ActiveSession):
|
|
47
|
+
return os.fspath(target)
|
|
48
|
+
compatibility = target.compatibility
|
|
49
|
+
if not compatibility.features["messaging"]:
|
|
50
|
+
reasons = " ".join(r for r in compatibility.reasons if r.startswith("messaging:"))
|
|
51
|
+
if target.messaging_socket is None and target.peer_protocol in SUPPORTED_PEER_PROTOCOLS:
|
|
52
|
+
raise MessagingUnavailable(f"The session {target.address} cannot receive messages. {reasons}")
|
|
53
|
+
raise UnsupportedProtocol(f"The session {target.address} is not compatible with cc-blackbox. {reasons}")
|
|
54
|
+
assert target.messaging_socket is not None
|
|
55
|
+
return os.fspath(target.messaging_socket)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def send_message(
|
|
59
|
+
target: MessagingTarget,
|
|
60
|
+
content: str,
|
|
61
|
+
*,
|
|
62
|
+
sender: str,
|
|
63
|
+
sender_name: str | None = None,
|
|
64
|
+
priority: Priority = "next",
|
|
65
|
+
msg_id: str | None = None,
|
|
66
|
+
timeout: float = 5.0,
|
|
67
|
+
) -> SentMessage:
|
|
68
|
+
"""Deliver ``content`` to a running session as a message from another Claude session.
|
|
69
|
+
|
|
70
|
+
The receiving model sees the text after a header saying another Claude session sent it, and before a footer
|
|
71
|
+
saying it did not come from the user. Delivery is best effort. The receiver accepts the frame without answering,
|
|
72
|
+
and may then:
|
|
73
|
+
|
|
74
|
+
- drop it when ``sender`` exceeds 30 messages in a burst or 1 message per 2 seconds sustained,
|
|
75
|
+
- drop it when the same content arrived within the last 30 seconds, so put a sequence number or time into it,
|
|
76
|
+
- drop it when 50 messages already wait in its queue,
|
|
77
|
+
- hold it until the user approves it, which a session in bypass-permissions mode does by default.
|
|
78
|
+
|
|
79
|
+
``sender`` goes into the frame's ``from`` field. The model never sees it; the receiver uses it as the key of its
|
|
80
|
+
rate limit and duplicate check, so give every independent sending process its own label. With ``sender_name``,
|
|
81
|
+
the content is wrapped in a ``<cross-session-message from="did:<sender>" from-name="<sender_name>">`` envelope
|
|
82
|
+
(see :func:`build_envelope`), and the receiver's terminal shows the message under that name. Without it, the
|
|
83
|
+
content arrives as it is and the model does not learn who sent it.
|
|
84
|
+
|
|
85
|
+
A ``target`` that is an :class:`ActiveSession` must be aligned for messaging (see :func:`check_compatibility`);
|
|
86
|
+
its Claude Code version does not matter. A path is used as it is.
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
MessagingUnavailable: the session has messaging turned off, or the platform is Windows.
|
|
90
|
+
UnsupportedProtocol: the session's messaging is not aligned with this package, see
|
|
91
|
+
:func:`check_compatibility`. The message lists the reasons.
|
|
92
|
+
MessageTooLarge: the frame exceeds 1 MiB; nothing was sent.
|
|
93
|
+
SessionGone: nobody listens on the socket.
|
|
94
|
+
MessagingError: any other failure to write.
|
|
95
|
+
ValueError: an argument is empty or malformed.
|
|
96
|
+
"""
|
|
97
|
+
socket_path = _socket_path(target)
|
|
98
|
+
if sender_name is not None:
|
|
99
|
+
content = build_envelope(content, from_address=f"did:{percent_encode_address(sender)}", from_name=sender_name)
|
|
100
|
+
frame = build_user_frame(content, sender=sender, priority=priority, msg_id=msg_id)
|
|
101
|
+
payload = encode_frame(frame)
|
|
102
|
+
default_transport().send(socket_path, payload, timeout=timeout)
|
|
103
|
+
return SentMessage(msg_id=frame["msg_id"], socket_path=socket_path, bytes_written=len(payload))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def rename_session(target: MessagingTarget, name: str, *, timeout: float = 5.0) -> None:
|
|
107
|
+
"""Ask a running session to rename itself to ``name``.
|
|
108
|
+
|
|
109
|
+
The session may still change the name: when another live session in its profile already holds the name, Claude
|
|
110
|
+
Code gives the newcomer a different one. Read the session's record again to see the result.
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
The same errors as :func:`send_message`.
|
|
114
|
+
"""
|
|
115
|
+
socket_path = _socket_path(target)
|
|
116
|
+
default_transport().send(socket_path, encode_frame(build_rename_frame(name)), timeout=timeout)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(slots=True)
|
|
120
|
+
class _Bucket:
|
|
121
|
+
tokens: float
|
|
122
|
+
updated: float
|
|
123
|
+
pending: list[str] = field(default_factory=list)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class RateLimitedSender:
|
|
127
|
+
"""Send messages under a client-side rate limit that stays below the receiver's.
|
|
128
|
+
|
|
129
|
+
The receiver drops messages above 30 in a burst or 1 per 2 seconds for each ``from`` label, and it does not tell
|
|
130
|
+
the sender. This class keeps a token bucket for each target socket, 25 messages refilled at 0.4 per second by
|
|
131
|
+
default. When the bucket is empty, :meth:`send` queues the message. The next call that finds a token, from
|
|
132
|
+
:meth:`send` or :meth:`flush`, sends every queued message for that target as one message, joined by ``separator``.
|
|
133
|
+
Coalescing also keeps a burst of identical texts from being dropped by the receiver's duplicate check.
|
|
134
|
+
|
|
135
|
+
A queued message is lost when sending it fails. Instances are thread-safe.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(
|
|
139
|
+
self,
|
|
140
|
+
sender: str,
|
|
141
|
+
*,
|
|
142
|
+
sender_name: str | None = None,
|
|
143
|
+
capacity: int = 25,
|
|
144
|
+
refill_per_second: float = 0.4,
|
|
145
|
+
priority: Priority = "next",
|
|
146
|
+
timeout: float = 5.0,
|
|
147
|
+
separator: str = "\n\n",
|
|
148
|
+
clock: Callable[[], float] = time.monotonic,
|
|
149
|
+
) -> None:
|
|
150
|
+
if capacity < 1 or refill_per_second <= 0:
|
|
151
|
+
raise ValueError("The capacity must be at least 1 and the refill rate must be positive.")
|
|
152
|
+
self.sender = sender
|
|
153
|
+
self.sender_name = sender_name
|
|
154
|
+
self.capacity = capacity
|
|
155
|
+
self.refill_per_second = refill_per_second
|
|
156
|
+
self.priority: Priority = priority
|
|
157
|
+
self.timeout = timeout
|
|
158
|
+
self.separator = separator
|
|
159
|
+
self._clock = clock
|
|
160
|
+
self._buckets: dict[str, _Bucket] = {}
|
|
161
|
+
self._lock = threading.Lock()
|
|
162
|
+
|
|
163
|
+
def _bucket(self, socket_path: str) -> _Bucket:
|
|
164
|
+
now = self._clock()
|
|
165
|
+
bucket = self._buckets.get(socket_path)
|
|
166
|
+
if bucket is None:
|
|
167
|
+
bucket = self._buckets[socket_path] = _Bucket(tokens=float(self.capacity), updated=now)
|
|
168
|
+
else:
|
|
169
|
+
bucket.tokens = min(float(self.capacity), bucket.tokens + (now - bucket.updated) * self.refill_per_second)
|
|
170
|
+
bucket.updated = now
|
|
171
|
+
return bucket
|
|
172
|
+
|
|
173
|
+
def _drain(self, socket_path: str, bucket: _Bucket) -> SentMessage | None:
|
|
174
|
+
if not bucket.pending or bucket.tokens < 1:
|
|
175
|
+
return None
|
|
176
|
+
content = self.separator.join(bucket.pending)
|
|
177
|
+
bucket.pending.clear()
|
|
178
|
+
bucket.tokens -= 1
|
|
179
|
+
return send_message(
|
|
180
|
+
socket_path,
|
|
181
|
+
content,
|
|
182
|
+
sender=self.sender,
|
|
183
|
+
sender_name=self.sender_name,
|
|
184
|
+
priority=self.priority,
|
|
185
|
+
timeout=self.timeout,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def send(self, target: MessagingTarget, content: str) -> SentMessage | None:
|
|
189
|
+
"""Send ``content`` now, together with anything queued for the target, or queue it when no token is left.
|
|
190
|
+
|
|
191
|
+
Returns the sent message, or ``None`` when ``content`` was queued.
|
|
192
|
+
|
|
193
|
+
Raises:
|
|
194
|
+
The errors of :func:`send_message`. The target checks run before anything is queued.
|
|
195
|
+
"""
|
|
196
|
+
if not content:
|
|
197
|
+
raise ValueError("The message content is empty. The receiver drops empty messages.")
|
|
198
|
+
socket_path = _socket_path(target)
|
|
199
|
+
with self._lock:
|
|
200
|
+
bucket = self._bucket(socket_path)
|
|
201
|
+
bucket.pending.append(content)
|
|
202
|
+
return self._drain(socket_path, bucket)
|
|
203
|
+
|
|
204
|
+
def flush(self) -> list[SentMessage]:
|
|
205
|
+
"""Send the queued messages of every target that has a token, and return what was sent."""
|
|
206
|
+
sent: list[SentMessage] = []
|
|
207
|
+
with self._lock:
|
|
208
|
+
for socket_path in list(self._buckets):
|
|
209
|
+
message = self._drain(socket_path, self._bucket(socket_path))
|
|
210
|
+
if message is not None:
|
|
211
|
+
sent.append(message)
|
|
212
|
+
return sent
|
|
213
|
+
|
|
214
|
+
def pending_count(self, target: MessagingTarget) -> int:
|
|
215
|
+
"""Return how many messages wait for a token for ``target``."""
|
|
216
|
+
with self._lock:
|
|
217
|
+
bucket = self._buckets.get(_socket_path(target))
|
|
218
|
+
return len(bucket.pending) if bucket is not None else 0
|
|
219
|
+
|
|
220
|
+
def seconds_until_next_token(self, target: MessagingTarget) -> float:
|
|
221
|
+
"""Return how long the next message to ``target`` would wait for a token; 0 when one is available."""
|
|
222
|
+
with self._lock:
|
|
223
|
+
bucket = self._bucket(_socket_path(target))
|
|
224
|
+
return max(0.0, (1 - bucket.tokens) / self.refill_per_second)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Frames of Claude Code's cross-session messaging protocol (peer protocol 1).
|
|
2
|
+
|
|
3
|
+
A frame is one JSON object on one ``\\n``-terminated line. The receiver never answers on the same connection.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import unicodedata
|
|
11
|
+
import uuid
|
|
12
|
+
from typing import Any, Literal
|
|
13
|
+
|
|
14
|
+
from cc_blackbox.errors import MessageTooLarge
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"ENVELOPE_TAG",
|
|
18
|
+
"MAX_LINE_LENGTH",
|
|
19
|
+
"Priority",
|
|
20
|
+
"build_envelope",
|
|
21
|
+
"build_rename_frame",
|
|
22
|
+
"build_user_frame",
|
|
23
|
+
"encode_frame",
|
|
24
|
+
"percent_encode_address",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
Priority = Literal["now", "next", "later"]
|
|
28
|
+
"""Where the receiver queues a message. ``now`` jumps ahead of queued work, ``later`` waits behind it."""
|
|
29
|
+
|
|
30
|
+
MAX_LINE_LENGTH = 1_048_576
|
|
31
|
+
"""The receiver drops the connection when one line exceeds this many UTF-16 code units."""
|
|
32
|
+
|
|
33
|
+
ENVELOPE_TAG = "cross-session-message"
|
|
34
|
+
|
|
35
|
+
_PRIORITIES = ("now", "next", "later")
|
|
36
|
+
_UUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE)
|
|
37
|
+
_ADDRESS = re.compile(r"[A-Za-z0-9%:_/.\\-]+")
|
|
38
|
+
_ADDRESS_SAFE = re.compile(r"[A-Za-z0-9:_/.\\-]")
|
|
39
|
+
# The receiver escapes an opener or closer of these tags inside a peer message body, so a body can never end the
|
|
40
|
+
# envelope early or open a nested one. ASCII only: Claude Code also catches Unicode look-alikes of "<", "/" and of
|
|
41
|
+
# the tag names, and escapes those on its side.
|
|
42
|
+
_NESTED_TAG = re.compile(
|
|
43
|
+
r"<(?!\\)(?=[^A-Za-z0-9_\-<>]*(?:cross-session-message|teammate-message|agent-message)(?:[^A-Za-z0-9_\-]|$))",
|
|
44
|
+
re.IGNORECASE,
|
|
45
|
+
)
|
|
46
|
+
_NAME_REMOVED_CATEGORIES = ("Cf", "Cc", "Cs", "Zl", "Zp")
|
|
47
|
+
_NAME_MAX_CODE_POINTS = 64
|
|
48
|
+
_JS_TRIM = "\t\n\v\f\r \u00a0\u1680" + "".join(map(chr, range(0x2000, 0x200B))) + "\u2028\u2029\u202f\u205f\u3000\ufeff"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def percent_encode_address(value: str) -> str:
|
|
52
|
+
"""Percent-encode ``value`` for the ``from`` attribute the way Claude Code encodes socket paths.
|
|
53
|
+
|
|
54
|
+
Every character outside ``[A-Za-z0-9:_/.\\-]`` becomes its UTF-8 bytes as uppercase ``%XX``.
|
|
55
|
+
"""
|
|
56
|
+
return "".join(
|
|
57
|
+
ch if _ADDRESS_SAFE.fullmatch(ch) else "".join(f"%{b:02X}" for b in ch.encode("utf-8", "surrogatepass"))
|
|
58
|
+
for ch in value
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _envelope_name(name: str) -> str:
|
|
63
|
+
# Claude Code's sanitizer: drop `"<>`, drop format, control, surrogate and line/paragraph separator characters,
|
|
64
|
+
# trim, cap at 64 code points. Claude Code appends an ellipsis after 64 code points, which the receiver's own
|
|
65
|
+
# round-trip check then rejects; cutting to 63 plus the ellipsis keeps the name within the limit so it survives.
|
|
66
|
+
text = re.sub(r'["<>]', "", name)
|
|
67
|
+
text = "".join(ch for ch in text if unicodedata.category(ch) not in _NAME_REMOVED_CATEGORIES).strip(_JS_TRIM)
|
|
68
|
+
if len(text) > _NAME_MAX_CODE_POINTS:
|
|
69
|
+
text = text[: _NAME_MAX_CODE_POINTS - 1] + "\u2026"
|
|
70
|
+
return text
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def escape_envelope_body(body: str) -> str:
|
|
74
|
+
"""Escape every ``<cross-session-message``, ``<teammate-message`` and ``<agent-message`` opener or closer in
|
|
75
|
+
``body`` as ``<\\...``, the way Claude Code's own sender does. Already escaped text is left alone."""
|
|
76
|
+
return _NESTED_TAG.sub("<\\\\", body)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def build_envelope(body: str, *, from_address: str, from_name: str | None = None) -> str:
|
|
80
|
+
"""Wrap ``body`` in a ``<cross-session-message>`` envelope, byte for byte as Claude Code's own sender does.
|
|
81
|
+
|
|
82
|
+
The receiver's terminal then shows the message as a peer message under ``from_name``, and the model sees
|
|
83
|
+
``from_address`` as the address to reply to. ``from_address`` must be a ``scheme:value`` address made of
|
|
84
|
+
``[A-Za-z0-9%:_/.\\-]``; :func:`percent_encode_address` makes any value fit. Use ``did:<label>`` for a sender that
|
|
85
|
+
cannot receive replies, and never the address of another session: the receiver does not verify it. ``from_name``
|
|
86
|
+
is cleaned the way Claude Code cleans it and is left out when nothing remains.
|
|
87
|
+
|
|
88
|
+
The ``from-session``, ``hop-chain`` and ``from-mode`` attributes are never written. ``from-mode`` in particular
|
|
89
|
+
would be a claim about the sender's permission mode.
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
ValueError: ``from_address`` contains a character the receiver does not accept.
|
|
93
|
+
"""
|
|
94
|
+
if not _ADDRESS.fullmatch(from_address):
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f"The envelope address {from_address!r} may contain only letters, digits and %:_/.\\- characters. "
|
|
97
|
+
"Use percent_encode_address() on the variable part."
|
|
98
|
+
)
|
|
99
|
+
attributes = f' from="{from_address}"'
|
|
100
|
+
name = _envelope_name(from_name) if from_name is not None else ""
|
|
101
|
+
if name:
|
|
102
|
+
attributes += f' from-name="{name}"'
|
|
103
|
+
return f"<{ENVELOPE_TAG}{attributes}>\n{escape_envelope_body(body)}\n</{ENVELOPE_TAG}>"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def build_user_frame(
|
|
107
|
+
content: str, *, sender: str, priority: Priority = "next", msg_id: str | None = None
|
|
108
|
+
) -> dict[str, Any]:
|
|
109
|
+
"""Build the frame that delivers ``content`` as a peer message.
|
|
110
|
+
|
|
111
|
+
``sender`` goes into the JSON ``from`` field. The receiver never shows it to the model, but uses it as the key of
|
|
112
|
+
its rate limit and its 30-second duplicate check, and shows it to the user when it holds the message for approval.
|
|
113
|
+
``msg_id`` must be a UUID; the receiver ignores any other value. It defaults to a random UUID.
|
|
114
|
+
|
|
115
|
+
The frame never carries ``session_id``: a value that differs from the receiver's makes it drop the message
|
|
116
|
+
without telling anyone.
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
ValueError: ``content`` or ``sender`` is empty, or ``priority`` or ``msg_id`` is not valid.
|
|
120
|
+
"""
|
|
121
|
+
if not content:
|
|
122
|
+
raise ValueError("The message content is empty. The receiver drops empty messages.")
|
|
123
|
+
if not sender:
|
|
124
|
+
raise ValueError("The sender label is empty. Pass a stable label that identifies the sending tool.")
|
|
125
|
+
if priority not in _PRIORITIES:
|
|
126
|
+
raise ValueError(f"The priority {priority!r} is not one of now, next or later.")
|
|
127
|
+
if msg_id is None:
|
|
128
|
+
msg_id = str(uuid.uuid4())
|
|
129
|
+
elif not _UUID.fullmatch(msg_id):
|
|
130
|
+
raise ValueError(f"The msg_id {msg_id!r} is not a UUID such as {uuid.uuid4()}. The receiver ignores it.")
|
|
131
|
+
return {
|
|
132
|
+
"type": "user",
|
|
133
|
+
"from": sender,
|
|
134
|
+
"priority": priority,
|
|
135
|
+
"msg_id": msg_id,
|
|
136
|
+
"message": {"role": "user", "content": content},
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def build_rename_frame(name: str) -> dict[str, Any]:
|
|
141
|
+
"""Build the control frame that renames the receiving session to ``name``.
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
ValueError: ``name`` is empty.
|
|
145
|
+
"""
|
|
146
|
+
if not name:
|
|
147
|
+
raise ValueError("The new session name is empty.")
|
|
148
|
+
return {"type": "control", "action": "rename", "name": name}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def encode_frame(frame: dict[str, Any]) -> bytes:
|
|
152
|
+
"""Serialise ``frame`` as one UTF-8 JSON line, newline included.
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
MessageTooLarge: the line exceeds :data:`MAX_LINE_LENGTH`, so the receiver would drop it.
|
|
156
|
+
"""
|
|
157
|
+
line = json.dumps(frame, ensure_ascii=False, separators=(",", ":"))
|
|
158
|
+
length = len(line.encode("utf-16-le", "surrogatepass")) // 2 + 1
|
|
159
|
+
if length > MAX_LINE_LENGTH:
|
|
160
|
+
raise MessageTooLarge(
|
|
161
|
+
f"The frame is {length} characters long and the receiver accepts at most {MAX_LINE_LENGTH}. "
|
|
162
|
+
"Shorten the message or split it."
|
|
163
|
+
)
|
|
164
|
+
return (line + "\n").encode("utf-8", "surrogatepass")
|