happy-engineering-sdk 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,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: happy-engineering-sdk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for controlling Happy agent sessions
5
+ Project-URL: Homepage, https://happy.engineering
6
+ Author-email: Scott Fraser <scott@jascro.com>
7
+ License: MIT
8
+ Keywords: agents,ai,engineering,happy,llm,sdk
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Requires-Python: >=3.11
17
+ Requires-Dist: cryptography>=43.0
18
+ Requires-Dist: httpx>=0.27
19
+ Requires-Dist: pynacl>=1.5
20
+ Requires-Dist: python-socketio[asyncio]>=5.11
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
23
+ Requires-Dist: pytest>=8; extra == 'dev'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # happy-engineering-sdk
27
+
28
+ [![PyPI version](https://img.shields.io/pypi/v/happy-engineering-sdk)](https://pypi.org/project/happy-engineering-sdk/)
29
+ [![Python versions](https://img.shields.io/pypi/pyversions/happy-engineering-sdk)](https://pypi.org/project/happy-engineering-sdk/)
30
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
31
+
32
+ Python SDK for controlling Happy agent sessions.
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install happy-engineering-sdk
38
+ ```
39
+
40
+ ## Credentials
41
+
42
+ The SDK authenticates using a key file and a server URL.
43
+
44
+ **Key file** — download `agent.key` from the Happy dashboard and place it at:
45
+
46
+ ```
47
+ ~/.happy/agent.key
48
+ ```
49
+
50
+ **Server URL** — set the `HAPPY_SERVER_URL` environment variable to your Happy
51
+ server's base URL:
52
+
53
+ ```bash
54
+ export HAPPY_SERVER_URL=https://api.happy.engineering
55
+ ```
56
+
57
+ ## Quick start
58
+
59
+ The simplest usage is `run_task()` — it spawns a session, sends a prompt, waits
60
+ for the agent to finish, and stops the session, all in one call:
61
+
62
+ ```python
63
+ import asyncio
64
+ from happy_sdk import HappyClient
65
+
66
+ async def main():
67
+ client = HappyClient()
68
+ session_id = await client.run_task(
69
+ machine_id="my-machine",
70
+ directory="/home/user/project",
71
+ prompt="Summarise this week's PRs",
72
+ )
73
+ print(f"Task complete — session {session_id}")
74
+
75
+ asyncio.run(main())
76
+ ```
77
+
78
+ ## Manual lifecycle
79
+
80
+ For finer control, manage the session lifecycle directly:
81
+
82
+ ```python
83
+ import asyncio
84
+ from happy_sdk import HappyClient
85
+
86
+ async def main():
87
+ client = HappyClient()
88
+
89
+ session_id = await client.spawn_session(
90
+ machine_id="my-machine",
91
+ directory="/home/user/project",
92
+ )
93
+ await client.send_message(session_id, "Hello")
94
+ await client.wait_for_turn_completion(session_id)
95
+ await client.stop_session(session_id)
96
+
97
+ asyncio.run(main())
98
+ ```
99
+
100
+ ## API reference
101
+
102
+ All methods are `async` and must be called from an async context.
103
+
104
+ | Method | Signature | Description |
105
+ |--------|-----------|-------------|
106
+ | `run_task` | `(machine_id, directory, prompt, agent="claude", timeout_seconds=600)` | Spawn a session, send a prompt, wait for turn completion, and stop — returns the session ID |
107
+ | `spawn_session` | `(machine_id, directory, agent="claude", create_dir=False)` | Create a new agent session on the given machine in the given directory — returns the session ID |
108
+ | `send_message` | `(session_id, text, permission_mode="yolo")` | Send a message to an active session |
109
+ | `wait_for_turn_completion` | `(session_id, timeout_seconds=300)` | Block until the agent finishes its current turn |
110
+ | `stop_session` | `(session_id)` | Stop a running session |
111
+
112
+ ## License
113
+
114
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,12 @@
1
+ happy_sdk/__init__.py,sha256=Ars9ST8tjeOutue0kwbzEQggjGUe73Xaa_zS0O4vLhA,540
2
+ happy_sdk/api.py,sha256=e1xOXUnQYgtmMrMB9fitoojv9pXwtI0yLBajHxZljE8,2093
3
+ happy_sdk/client.py,sha256=DbOGfjJZaZcBjZ3YcJKRRDsldiHeueA28DxrumQgcn0,9938
4
+ happy_sdk/credentials.py,sha256=wxSt_dE2fFGvE71gLzPlvzuTa67K6p2xNW7nShe_0OM,1268
5
+ happy_sdk/encryption.py,sha256=Av4-vPRz_esQq62EbiTxGUVpoTbYzL2cHS9vx6xeCqk,4399
6
+ happy_sdk/exceptions.py,sha256=DQWop_jXsYaOtfjf_sflI2qTccd878uTDNt_oXUF9VU,600
7
+ happy_sdk/session_connection.py,sha256=RdkKEx99eAUl8YzaV1beDkYLAbgQXoVQx7gju_Xartg,4722
8
+ happy_sdk/socket_manager.py,sha256=4pHO3aQmo_LnUt1A9py5IKvTJ1KBQSNKXItEm-FDMnA,3428
9
+ happy_sdk/types.py,sha256=IBkx0LyB7ME0LtF5l2klzWXZcsRuC9HG5kiX_kpdEaU,574
10
+ happy_engineering_sdk-0.1.0.dist-info/METADATA,sha256=J85S3iqOcS8B5f8jJYB-0GyTY4NLhsiy5YywbFyP7DQ,3621
11
+ happy_engineering_sdk-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
12
+ happy_engineering_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
happy_sdk/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ from .client import HappyClient
2
+ from .types import Agent, Machine, Message, PermissionMode, Session
3
+ from .exceptions import (
4
+ AuthenticationError,
5
+ ConnectionError,
6
+ EncryptionError,
7
+ HappyError,
8
+ MachineOfflineError,
9
+ SpawnError,
10
+ TimeoutError,
11
+ )
12
+
13
+ __all__ = [
14
+ "HappyClient",
15
+ "Machine",
16
+ "Session",
17
+ "Message",
18
+ "Agent",
19
+ "PermissionMode",
20
+ "HappyError",
21
+ "AuthenticationError",
22
+ "ConnectionError",
23
+ "EncryptionError",
24
+ "MachineOfflineError",
25
+ "SpawnError",
26
+ "TimeoutError",
27
+ ]
happy_sdk/api.py ADDED
@@ -0,0 +1,56 @@
1
+ from typing import Any
2
+
3
+ import httpx
4
+
5
+ from .exceptions import AuthenticationError, HappyError
6
+
7
+
8
+ class HappyAPI:
9
+ def __init__(self, server_url: str, token: str) -> None:
10
+ self._base_url = server_url.rstrip("/")
11
+ self._token = token
12
+
13
+ def _headers(self) -> dict[str, str]:
14
+ return {"Authorization": f"Bearer {self._token}"}
15
+
16
+ async def list_machines(self) -> list[dict[str, Any]]:
17
+ async with httpx.AsyncClient() as client:
18
+ r = await client.get(f"{self._base_url}/v1/machines", headers=self._headers())
19
+ r.raise_for_status()
20
+ return r.json()
21
+
22
+ async def list_sessions(self) -> list[dict[str, Any]]:
23
+ async with httpx.AsyncClient() as client:
24
+ r = await client.get(f"{self._base_url}/v1/sessions", headers=self._headers())
25
+ r.raise_for_status()
26
+ return r.json()
27
+
28
+ async def list_active_sessions(self) -> list[dict[str, Any]]:
29
+ async with httpx.AsyncClient() as client:
30
+ r = await client.get(f"{self._base_url}/v2/sessions/active", headers=self._headers())
31
+ r.raise_for_status()
32
+ return r.json()
33
+
34
+ async def get_session(self, session_id: str) -> dict[str, Any]:
35
+ async with httpx.AsyncClient() as client:
36
+ r = await client.get(
37
+ f"{self._base_url}/v1/sessions/{session_id}", headers=self._headers()
38
+ )
39
+ r.raise_for_status()
40
+ return r.json()
41
+
42
+ async def get_messages(self, session_id: str) -> list[dict[str, Any]]:
43
+ async with httpx.AsyncClient() as client:
44
+ r = await client.get(
45
+ f"{self._base_url}/v1/sessions/{session_id}/messages",
46
+ headers=self._headers(),
47
+ )
48
+ r.raise_for_status()
49
+ return r.json()
50
+
51
+ async def delete_session(self, session_id: str) -> None:
52
+ async with httpx.AsyncClient() as client:
53
+ r = await client.delete(
54
+ f"{self._base_url}/v1/sessions/{session_id}", headers=self._headers()
55
+ )
56
+ r.raise_for_status()
happy_sdk/client.py ADDED
@@ -0,0 +1,255 @@
1
+ import os
2
+ from typing import Any
3
+
4
+ from .api import HappyAPI
5
+ from .credentials import Credentials, load_credentials
6
+ from .encryption import (
7
+ decode_base64,
8
+ decrypt,
9
+ decrypt_box_bundle,
10
+ derive_content_key_pair,
11
+ encode_base64,
12
+ encrypt,
13
+ )
14
+ from .exceptions import HappyError, MachineOfflineError, SpawnError
15
+ from .session_connection import SessionConnection
16
+ from .socket_manager import SocketManager
17
+ from .types import Agent, Machine, Message, PermissionMode, Session
18
+
19
+
20
+ class HappyClient:
21
+ def __init__(
22
+ self,
23
+ server_url: str | None = None,
24
+ credentials_path: str | None = None,
25
+ ) -> None:
26
+ if server_url is None:
27
+ server_url = os.environ.get("HAPPY_SERVER_URL")
28
+ if not server_url:
29
+ raise ValueError("server_url is required (or set HAPPY_SERVER_URL)")
30
+
31
+ self._server_url = server_url.rstrip("/")
32
+ self._creds: Credentials = load_credentials(credentials_path)
33
+ self._api = HappyAPI(self._server_url, self._creds.token)
34
+ self._sockets = SocketManager(self._server_url, self._creds.token)
35
+ # Derive the NaCl content keypair once for decrypting dataEncryptionKey bundles
36
+ _pub, self._content_secret = derive_content_key_pair(self._creds.secret)
37
+
38
+ # -----------------------------------------------------------------------
39
+ # Key resolution
40
+ # -----------------------------------------------------------------------
41
+
42
+ async def _resolve_session_key(self, session_id: str) -> tuple[bytes, str]:
43
+ """Return (key_bytes, variant) for a session."""
44
+ raw = await self._api.get_session(session_id)
45
+ return self._extract_key(raw)
46
+
47
+ async def _resolve_machine_key(self, machine_id: str) -> tuple[bytes, str]:
48
+ """Return (key_bytes, variant) for a machine."""
49
+ machines = await self._api.list_machines()
50
+ for m in machines:
51
+ if m.get("id") == machine_id:
52
+ return self._extract_key(m)
53
+ raise MachineOfflineError(f"Machine {machine_id} not found")
54
+
55
+ def _extract_key(self, record: dict[str, Any]) -> tuple[bytes, str]:
56
+ dek = record.get("dataEncryptionKey")
57
+ if dek:
58
+ bundle = decode_base64(dek)
59
+ # Strip 1-byte version prefix if present
60
+ if len(bundle) > 56:
61
+ bundle = bundle[1:]
62
+ key = decrypt_box_bundle(bundle, self._content_secret)
63
+ if key is None:
64
+ from .exceptions import EncryptionError
65
+ raise EncryptionError("Failed to decrypt dataEncryptionKey")
66
+ return key, "dataKey"
67
+ return self._creds.secret, "legacy"
68
+
69
+ # -----------------------------------------------------------------------
70
+ # Machine operations
71
+ # -----------------------------------------------------------------------
72
+
73
+ async def list_machines(self, active_only: bool = False) -> list[Machine]:
74
+ raw = await self._api.list_machines()
75
+ machines = [
76
+ Machine(id=m["id"], active=m.get("active", False), metadata=m.get("metadata", {}))
77
+ for m in raw
78
+ ]
79
+ if active_only:
80
+ machines = [m for m in machines if m.active]
81
+ return machines
82
+
83
+ async def get_machine(self, machine_id: str) -> Machine:
84
+ machines = await self._api.list_machines()
85
+ for m in machines:
86
+ if m["id"] == machine_id:
87
+ return Machine(
88
+ id=m["id"], active=m.get("active", False), metadata=m.get("metadata", {})
89
+ )
90
+ raise HappyError(f"Machine {machine_id} not found")
91
+
92
+ # -----------------------------------------------------------------------
93
+ # Session lifecycle
94
+ # -----------------------------------------------------------------------
95
+
96
+ async def spawn_session(
97
+ self,
98
+ machine_id: str,
99
+ directory: str,
100
+ agent: Agent = "claude",
101
+ create_dir: bool = False,
102
+ ) -> str:
103
+ machine_key, machine_variant = await self._resolve_machine_key(machine_id)
104
+ payload = {
105
+ "type": "spawn-in-directory",
106
+ "directory": directory,
107
+ "approvedNewDirectoryCreation": create_dir,
108
+ "agent": agent,
109
+ }
110
+ encrypted = encrypt(machine_key, machine_variant, payload)
111
+ ack = await self._sockets.rpc_call(
112
+ f"{machine_id}:spawn-happy-session",
113
+ encode_base64(encrypted),
114
+ )
115
+ result_b64 = ack.get("result", "")
116
+ result = decrypt(machine_key, machine_variant, decode_base64(result_b64))
117
+ if not result or result.get("type") != "success":
118
+ msg = result.get("errorMessage", "unknown") if result else "decrypt failed"
119
+ raise SpawnError(f"Spawn failed: {msg}")
120
+ session_id = result.get("sessionId")
121
+ if not session_id:
122
+ raise SpawnError("Spawn succeeded but returned no sessionId")
123
+ return session_id
124
+
125
+ async def stop_session(self, session_id: str) -> None:
126
+ key, variant = await self._resolve_session_key(session_id)
127
+ sock = await self._sockets.open_session_socket(session_id)
128
+ conn = SessionConnection(sock, session_id, key, variant)
129
+ try:
130
+ await conn.send_stop()
131
+ finally:
132
+ await sock.disconnect()
133
+
134
+ async def delete_session(self, session_id: str) -> None:
135
+ await self._api.delete_session(session_id)
136
+
137
+ # -----------------------------------------------------------------------
138
+ # Messaging
139
+ # -----------------------------------------------------------------------
140
+
141
+ async def send_message(
142
+ self,
143
+ session_id: str,
144
+ text: str,
145
+ permission_mode: PermissionMode = "yolo",
146
+ ) -> None:
147
+ key, variant = await self._resolve_session_key(session_id)
148
+ sock = await self._sockets.open_session_socket(session_id)
149
+ conn = SessionConnection(sock, session_id, key, variant)
150
+ try:
151
+ await conn.send_message(text, permission_mode)
152
+ finally:
153
+ await sock.disconnect()
154
+
155
+ # -----------------------------------------------------------------------
156
+ # Waiting
157
+ # -----------------------------------------------------------------------
158
+
159
+ async def wait_for_turn_completion(
160
+ self, session_id: str, timeout_seconds: float = 300
161
+ ) -> None:
162
+ key, variant = await self._resolve_session_key(session_id)
163
+ sock = await self._sockets.open_session_socket(session_id)
164
+ conn = SessionConnection(sock, session_id, key, variant)
165
+ try:
166
+ await conn.wait_for_turn_completion(timeout_seconds)
167
+ finally:
168
+ await sock.disconnect()
169
+
170
+ async def wait_for_idle(
171
+ self, session_id: str, timeout_seconds: float = 300
172
+ ) -> None:
173
+ key, variant = await self._resolve_session_key(session_id)
174
+ sock = await self._sockets.open_session_socket(session_id)
175
+ conn = SessionConnection(sock, session_id, key, variant)
176
+ try:
177
+ await conn.wait_for_idle(timeout_seconds)
178
+ finally:
179
+ await sock.disconnect()
180
+
181
+ # -----------------------------------------------------------------------
182
+ # Query
183
+ # -----------------------------------------------------------------------
184
+
185
+ async def list_sessions(self, active_only: bool = False) -> list[Session]:
186
+ if active_only:
187
+ raw = await self._api.list_active_sessions()
188
+ else:
189
+ raw = await self._api.list_sessions()
190
+ return [
191
+ Session(
192
+ id=s["id"],
193
+ active=s.get("active", False),
194
+ created_at=s.get("createdAt", 0),
195
+ metadata=s.get("metadata", {}),
196
+ agent_state=s.get("agentState"),
197
+ )
198
+ for s in raw
199
+ ]
200
+
201
+ async def get_session(self, session_id: str) -> Session:
202
+ s = await self._api.get_session(session_id)
203
+ return Session(
204
+ id=s["id"],
205
+ active=s.get("active", False),
206
+ created_at=s.get("createdAt", 0),
207
+ metadata=s.get("metadata", {}),
208
+ agent_state=s.get("agentState"),
209
+ )
210
+
211
+ async def get_messages(self, session_id: str) -> list[Message]:
212
+ key, variant = await self._resolve_session_key(session_id)
213
+ raw = await self._api.get_messages(session_id)
214
+ messages = []
215
+ for m in raw:
216
+ content = m.get("content", {})
217
+ # Decrypt if still encrypted on the wire
218
+ if isinstance(content, dict) and content.get("t") == "encrypted":
219
+ c = content.get("c", "")
220
+ decrypted = decrypt(key, variant, decode_base64(c))
221
+ content = decrypted if decrypted is not None else content
222
+ messages.append(
223
+ Message(
224
+ id=m["id"],
225
+ seq=m.get("seq", 0),
226
+ content=content,
227
+ created_at=m.get("createdAt", 0),
228
+ )
229
+ )
230
+ return messages
231
+
232
+ # -----------------------------------------------------------------------
233
+ # Convenience
234
+ # -----------------------------------------------------------------------
235
+
236
+ async def run_task(
237
+ self,
238
+ machine_id: str,
239
+ directory: str,
240
+ prompt: str,
241
+ agent: Agent = "claude",
242
+ timeout_seconds: float = 600,
243
+ ) -> str:
244
+ session_id = await self.spawn_session(machine_id, directory, agent)
245
+ await self.send_message(session_id, prompt)
246
+ await self.wait_for_turn_completion(session_id, timeout_seconds)
247
+ await self.stop_session(session_id)
248
+ return session_id
249
+
250
+ # -----------------------------------------------------------------------
251
+ # Cleanup
252
+ # -----------------------------------------------------------------------
253
+
254
+ async def close(self) -> None:
255
+ pass # Connections are per-call; nothing persistent to close in v1
@@ -0,0 +1,44 @@
1
+ import base64
2
+ import json
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from .exceptions import AuthenticationError
8
+
9
+
10
+ @dataclass
11
+ class Credentials:
12
+ token: str
13
+ secret: bytes
14
+
15
+
16
+ def load_credentials(path: str | None = None) -> Credentials:
17
+ if path is None:
18
+ home_dir = os.environ.get("HAPPY_HOME_DIR", str(Path.home() / ".happy"))
19
+ path = os.path.join(home_dir, "agent.key")
20
+
21
+ path = os.path.expanduser(path)
22
+ try:
23
+ with open(path) as f:
24
+ data = json.load(f)
25
+ except FileNotFoundError:
26
+ raise AuthenticationError(
27
+ f"Credentials file not found: {path}. Run 'happy-agent auth login' first."
28
+ )
29
+ except (json.JSONDecodeError, IOError) as e:
30
+ raise AuthenticationError(f"Failed to read credentials from {path}: {e}")
31
+
32
+ token = data.get("token")
33
+ secret_b64 = data.get("secret")
34
+ if not token or not secret_b64:
35
+ raise AuthenticationError(
36
+ f"Credentials file {path} is missing 'token' or 'secret' fields."
37
+ )
38
+
39
+ try:
40
+ secret = base64.b64decode(secret_b64)
41
+ except Exception as e:
42
+ raise AuthenticationError(f"Failed to decode secret from credentials: {e}")
43
+
44
+ return Credentials(token=token, secret=secret)
@@ -0,0 +1,135 @@
1
+ import base64
2
+ import hashlib
3
+ import hmac
4
+ import json
5
+ import os
6
+ from typing import Any
7
+
8
+ import nacl.public
9
+ import nacl.secret
10
+ import nacl.utils
11
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
12
+
13
+ from .exceptions import EncryptionError
14
+
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Base64 helpers
18
+ # ---------------------------------------------------------------------------
19
+
20
+ def encode_base64(data: bytes) -> str:
21
+ return base64.b64encode(data).decode()
22
+
23
+
24
+ def decode_base64(b64: str) -> bytes:
25
+ return base64.b64decode(b64)
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Key derivation
30
+ # ---------------------------------------------------------------------------
31
+
32
+ def hmac_sha512(key: bytes, data: bytes) -> bytes:
33
+ return hmac.new(key, data, hashlib.sha512).digest()
34
+
35
+
36
+ def derive_key(master: bytes, usage: str, path: list[str]) -> bytes:
37
+ seed_key = f"{usage} Master Seed".encode()
38
+ root = hmac_sha512(seed_key, master)
39
+ key, chain = root[:32], root[32:]
40
+ for segment in path:
41
+ child = hmac_sha512(chain, b"\x00" + segment.encode())
42
+ key, chain = child[:32], child[32:]
43
+ return key
44
+
45
+
46
+ def derive_content_key_pair(secret: bytes) -> tuple[bytes, bytes]:
47
+ """Return (public_key_bytes, private_key_bytes) for NaCl box operations."""
48
+ raw = derive_key(secret, "Happy EnCoder", ["content"])
49
+ seed = hashlib.sha512(raw).digest()[:32]
50
+ private_key = nacl.public.PrivateKey(seed)
51
+ return bytes(private_key.public_key), bytes(private_key)
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # AES-256-GCM (wire: 0x00 + nonce[12] + ct+tag)
56
+ # ---------------------------------------------------------------------------
57
+
58
+ def encrypt_aes_gcm(data: Any, key: bytes) -> bytes:
59
+ plaintext = json.dumps(data).encode()
60
+ nonce = os.urandom(12)
61
+ ct = AESGCM(key).encrypt(nonce, plaintext, None)
62
+ return b"\x00" + nonce + ct
63
+
64
+
65
+ def decrypt_aes_gcm(bundle: bytes, key: bytes) -> Any | None:
66
+ try:
67
+ if bundle[0] != 0x00:
68
+ return None
69
+ nonce = bundle[1:13]
70
+ ct = bundle[13:]
71
+ plaintext = AESGCM(key).decrypt(nonce, ct, None)
72
+ return json.loads(plaintext)
73
+ except Exception:
74
+ return None
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # NaCl secretbox (legacy variant — wire: nonce[24] + ct)
79
+ # ---------------------------------------------------------------------------
80
+
81
+ def encrypt_secretbox(data: Any, key: bytes) -> bytes:
82
+ plaintext = json.dumps(data).encode()
83
+ box = nacl.secret.SecretBox(key)
84
+ return bytes(box.encrypt(plaintext))
85
+
86
+
87
+ def decrypt_secretbox(bundle: bytes, key: bytes) -> Any | None:
88
+ try:
89
+ box = nacl.secret.SecretBox(key)
90
+ plaintext = box.decrypt(bundle)
91
+ return json.loads(plaintext)
92
+ except Exception:
93
+ return None
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # NaCl box bundle (wire: ephemeral_pub[32] + nonce[24] + ct)
98
+ # ---------------------------------------------------------------------------
99
+
100
+ def decrypt_box_bundle(bundle: bytes, secret_key: bytes) -> bytes | None:
101
+ try:
102
+ ephemeral_pub = bundle[:32]
103
+ nonce = bundle[32:56]
104
+ ct = bundle[56:]
105
+ box = nacl.public.Box(
106
+ nacl.public.PrivateKey(secret_key),
107
+ nacl.public.PublicKey(ephemeral_pub),
108
+ )
109
+ return bytes(box.decrypt(ct, nonce))
110
+ except Exception:
111
+ return None
112
+
113
+
114
+ def encrypt_for_public_key(data: bytes, public_key: bytes) -> bytes:
115
+ ephemeral = nacl.public.PrivateKey.generate()
116
+ box = nacl.public.Box(ephemeral, nacl.public.PublicKey(public_key))
117
+ nonce = nacl.utils.random(24)
118
+ ct = box.encrypt(data, nonce).ciphertext
119
+ return bytes(ephemeral.public_key) + nonce + ct
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # Dispatcher
124
+ # ---------------------------------------------------------------------------
125
+
126
+ def encrypt(key: bytes, variant: str, data: Any) -> bytes:
127
+ if variant == "dataKey":
128
+ return encrypt_aes_gcm(data, key)
129
+ return encrypt_secretbox(data, key)
130
+
131
+
132
+ def decrypt(key: bytes, variant: str, bundle: bytes) -> Any | None:
133
+ if variant == "dataKey":
134
+ return decrypt_aes_gcm(bundle, key)
135
+ return decrypt_secretbox(bundle, key)
@@ -0,0 +1,26 @@
1
+ class HappyError(Exception):
2
+ """Base class for all happy-sdk errors."""
3
+
4
+
5
+ class AuthenticationError(HappyError):
6
+ """Credentials missing, expired, or malformed."""
7
+
8
+
9
+ class MachineOfflineError(HappyError):
10
+ """Target machine is not connected to the server."""
11
+
12
+
13
+ class SpawnError(HappyError):
14
+ """Session spawn failed."""
15
+
16
+
17
+ class TimeoutError(HappyError):
18
+ """Wait exceeded the specified timeout."""
19
+
20
+
21
+ class EncryptionError(HappyError):
22
+ """Encrypt or decrypt operation failed."""
23
+
24
+
25
+ class ConnectionError(HappyError):
26
+ """Socket connection failed or disconnected unexpectedly."""
@@ -0,0 +1,121 @@
1
+ import asyncio
2
+ import time
3
+ from typing import Any
4
+
5
+ from .encryption import decode_base64, decrypt, encode_base64, encrypt
6
+ from .exceptions import TimeoutError
7
+ from .socket_manager import SessionSocket
8
+
9
+
10
+ class SessionConnection:
11
+ def __init__(
12
+ self,
13
+ socket: SessionSocket,
14
+ session_id: str,
15
+ session_key: bytes,
16
+ variant: str,
17
+ ) -> None:
18
+ self._socket = socket
19
+ self._session_id = session_id
20
+ self._session_key = session_key
21
+ self._variant = variant
22
+
23
+ async def send_message(self, text: str, permission_mode: str = "yolo") -> None:
24
+ content = {
25
+ "role": "user",
26
+ "content": {"type": "text", "text": text},
27
+ "meta": {"sentFrom": "happy-sdk-python", "permissionMode": permission_mode},
28
+ }
29
+ encrypted = encrypt(self._session_key, self._variant, content)
30
+ await self._socket.emit(
31
+ "message",
32
+ {"sid": self._session_id, "message": encode_base64(encrypted)},
33
+ )
34
+
35
+ async def send_stop(self) -> None:
36
+ await self._socket.emit(
37
+ "session-end",
38
+ {"sid": self._session_id, "time": int(time.time() * 1000)},
39
+ )
40
+
41
+ async def wait_for_turn_completion(self, timeout: float = 300) -> None:
42
+ deadline = time.monotonic() + timeout
43
+ while True:
44
+ remaining = deadline - time.monotonic()
45
+ if remaining <= 0:
46
+ raise TimeoutError(f"wait_for_turn_completion timed out after {timeout}s")
47
+ update = await self._socket.wait_for_update(remaining)
48
+ if update is None:
49
+ raise TimeoutError(f"wait_for_turn_completion timed out after {timeout}s")
50
+ if self._is_turn_complete(update):
51
+ return
52
+
53
+ async def wait_for_idle(self, timeout: float = 300) -> None:
54
+ deadline = time.monotonic() + timeout
55
+ while True:
56
+ remaining = deadline - time.monotonic()
57
+ if remaining <= 0:
58
+ raise TimeoutError(f"wait_for_idle timed out after {timeout}s")
59
+ update = await self._socket.wait_for_update(remaining)
60
+ if update is None:
61
+ raise TimeoutError(f"wait_for_idle timed out after {timeout}s")
62
+ if self._is_turn_complete(update):
63
+ return
64
+ if self._is_idle(update):
65
+ return
66
+
67
+ def _decrypt_update(self, update: dict[str, Any]) -> Any | None:
68
+ """Decrypt content from an update body dict."""
69
+ body = update.get("body", update)
70
+ t = body.get("t")
71
+
72
+ if t == "new-message":
73
+ msg = body.get("message", {})
74
+ content = msg.get("content", {})
75
+ # May be {"t": "encrypted", "c": "<base64>"} or already decrypted
76
+ if isinstance(content, dict) and content.get("t") == "encrypted":
77
+ raw = decode_base64(content["c"])
78
+ return decrypt(self._session_key, self._variant, raw)
79
+ return content
80
+
81
+ if t == "update-session":
82
+ agent_state = body.get("agentState", {})
83
+ value = agent_state.get("value")
84
+ if value:
85
+ raw = decode_base64(value)
86
+ return decrypt(self._session_key, self._variant, raw)
87
+ return agent_state
88
+
89
+ return None
90
+
91
+ def _is_turn_complete(self, update: dict[str, Any]) -> bool:
92
+ body = update.get("body", update)
93
+ if body.get("t") != "new-message":
94
+ return False
95
+ decrypted = self._decrypt_update(update)
96
+ if not isinstance(decrypted, dict):
97
+ return False
98
+ # session turn-end: {"role": "session", "content": {"turn": "...", "ev": {"t": "turn-end"}}}
99
+ content = decrypted.get("content", {})
100
+ if isinstance(content, dict):
101
+ ev = content.get("ev", {})
102
+ if isinstance(ev, dict) and ev.get("t") == "turn-end":
103
+ return True
104
+ # agent ready: {"role": "agent", "content": {"type": "event", "data": {"type": "ready"}}}
105
+ if isinstance(content, dict) and content.get("type") == "event":
106
+ data = content.get("data", {})
107
+ if isinstance(data, dict) and data.get("type") == "ready":
108
+ return True
109
+ return False
110
+
111
+ def _is_idle(self, update: dict[str, Any]) -> bool:
112
+ body = update.get("body", update)
113
+ if body.get("t") != "update-session":
114
+ return False
115
+ decrypted = self._decrypt_update(update)
116
+ if not isinstance(decrypted, dict):
117
+ return False
118
+ if decrypted.get("controlledByUser") is not False:
119
+ return False
120
+ requests = decrypted.get("requests", {})
121
+ return len(requests) == 0
@@ -0,0 +1,100 @@
1
+ import asyncio
2
+ from typing import Any
3
+
4
+ import socketio
5
+
6
+ from .exceptions import ConnectionError, SpawnError
7
+
8
+
9
+ _SIO_PATH = "/v1/updates"
10
+ _SIO_TRANSPORT = ["websocket"]
11
+
12
+
13
+ class SessionSocket:
14
+ """Thin wrapper around a connected session-scoped socketio.AsyncClient."""
15
+
16
+ def __init__(self, sio: socketio.AsyncClient, session_id: str) -> None:
17
+ self._sio = sio
18
+ self._session_id = session_id
19
+ self._update_event: asyncio.Event = asyncio.Event()
20
+ self._last_update: dict[str, Any] | None = None
21
+
22
+ @sio.on("update")
23
+ def _on_update(data: dict[str, Any]) -> None:
24
+ self._last_update = data
25
+ self._update_event.set()
26
+
27
+ async def emit(self, event: str, data: dict[str, Any]) -> None:
28
+ await self._sio.emit(event, data)
29
+
30
+ async def wait_for_update(self, timeout: float) -> dict[str, Any] | None:
31
+ """Block until an 'update' event arrives or timeout. Returns the body or None."""
32
+ self._update_event.clear()
33
+ try:
34
+ await asyncio.wait_for(self._update_event.wait(), timeout=timeout)
35
+ except asyncio.TimeoutError:
36
+ return None
37
+ data = self._last_update
38
+ self._last_update = None
39
+ return data
40
+
41
+ async def disconnect(self) -> None:
42
+ await self._sio.disconnect()
43
+
44
+
45
+ class SocketManager:
46
+ """Creates and manages Socket.IO connections to the Happy server."""
47
+
48
+ def __init__(self, server_url: str, token: str) -> None:
49
+ self._server_url = server_url
50
+ self._token = token
51
+
52
+ async def rpc_call(self, method: str, params: str) -> dict[str, Any]:
53
+ """
54
+ One-shot user-scoped connection. Emits rpc-call, waits for ack, disconnects.
55
+ Returns the ack result dict or raises SpawnError.
56
+ """
57
+ sio = socketio.AsyncClient(logger=False, engineio_logger=False)
58
+ try:
59
+ await sio.connect(
60
+ self._server_url,
61
+ auth={"token": self._token},
62
+ socketio_path=_SIO_PATH,
63
+ transports=_SIO_TRANSPORT,
64
+ )
65
+ result = await sio.call("rpc-call", {"method": method, "params": params})
66
+ except Exception as e:
67
+ raise SpawnError(f"RPC call failed: {e}") from e
68
+ finally:
69
+ try:
70
+ await sio.disconnect()
71
+ except Exception:
72
+ pass
73
+
74
+ if not isinstance(result, dict):
75
+ raise SpawnError(f"Unexpected RPC ack type: {type(result)}")
76
+ if not result.get("ok"):
77
+ raise SpawnError(f"RPC error: {result.get('error', 'unknown')}")
78
+ return result
79
+
80
+ async def open_session_socket(self, session_id: str) -> SessionSocket:
81
+ """Return a connected session-scoped socket wrapper."""
82
+ sio = socketio.AsyncClient(
83
+ logger=False,
84
+ engineio_logger=False,
85
+ reconnection=True,
86
+ )
87
+ try:
88
+ await sio.connect(
89
+ self._server_url,
90
+ auth={
91
+ "token": self._token,
92
+ "clientType": "session-scoped",
93
+ "sessionId": session_id,
94
+ },
95
+ socketio_path=_SIO_PATH,
96
+ transports=_SIO_TRANSPORT,
97
+ )
98
+ except Exception as e:
99
+ raise ConnectionError(f"Failed to open session socket for {session_id}: {e}") from e
100
+ return SessionSocket(sio, session_id)
happy_sdk/types.py ADDED
@@ -0,0 +1,29 @@
1
+ from typing import Any, Literal
2
+ from dataclasses import dataclass, field
3
+
4
+ Agent = Literal["claude", "codex", "gemini", "openclaw"]
5
+ PermissionMode = Literal["yolo", "default"]
6
+
7
+
8
+ @dataclass
9
+ class Machine:
10
+ id: str
11
+ active: bool
12
+ metadata: dict[str, Any] = field(default_factory=dict)
13
+
14
+
15
+ @dataclass
16
+ class Session:
17
+ id: str
18
+ active: bool
19
+ created_at: int
20
+ metadata: dict[str, Any] = field(default_factory=dict)
21
+ agent_state: dict[str, Any] | None = None
22
+
23
+
24
+ @dataclass
25
+ class Message:
26
+ id: str
27
+ seq: int
28
+ content: dict[str, Any]
29
+ created_at: int