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.
- happy_engineering_sdk-0.1.0.dist-info/METADATA +114 -0
- happy_engineering_sdk-0.1.0.dist-info/RECORD +12 -0
- happy_engineering_sdk-0.1.0.dist-info/WHEEL +4 -0
- happy_sdk/__init__.py +27 -0
- happy_sdk/api.py +56 -0
- happy_sdk/client.py +255 -0
- happy_sdk/credentials.py +44 -0
- happy_sdk/encryption.py +135 -0
- happy_sdk/exceptions.py +26 -0
- happy_sdk/session_connection.py +121 -0
- happy_sdk/socket_manager.py +100 -0
- happy_sdk/types.py +29 -0
|
@@ -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
|
+
[](https://pypi.org/project/happy-engineering-sdk/)
|
|
29
|
+
[](https://pypi.org/project/happy-engineering-sdk/)
|
|
30
|
+
[](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,,
|
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
|
happy_sdk/credentials.py
ADDED
|
@@ -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)
|
happy_sdk/encryption.py
ADDED
|
@@ -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)
|
happy_sdk/exceptions.py
ADDED
|
@@ -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
|