happy-engineering-sdk 0.1.0__tar.gz
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/.gitignore +11 -0
- happy_engineering_sdk-0.1.0/PKG-INFO +114 -0
- happy_engineering_sdk-0.1.0/README.md +89 -0
- happy_engineering_sdk-0.1.0/happy_sdk/__init__.py +27 -0
- happy_engineering_sdk-0.1.0/happy_sdk/api.py +56 -0
- happy_engineering_sdk-0.1.0/happy_sdk/client.py +255 -0
- happy_engineering_sdk-0.1.0/happy_sdk/credentials.py +44 -0
- happy_engineering_sdk-0.1.0/happy_sdk/encryption.py +135 -0
- happy_engineering_sdk-0.1.0/happy_sdk/exceptions.py +26 -0
- happy_engineering_sdk-0.1.0/happy_sdk/session_connection.py +121 -0
- happy_engineering_sdk-0.1.0/happy_sdk/socket_manager.py +100 -0
- happy_engineering_sdk-0.1.0/happy_sdk/types.py +29 -0
- happy_engineering_sdk-0.1.0/pyproject.toml +45 -0
- happy_engineering_sdk-0.1.0/scripts/publish.sh +23 -0
- happy_engineering_sdk-0.1.0/spec/.metadata.json +1 -0
- happy_engineering_sdk-0.1.0/spec/chores/publish-happy-engineering-sdk.md +13 -0
- happy_engineering_sdk-0.1.0/spec/functional-backlog.md +3 -0
- happy_engineering_sdk-0.1.0/spec/functional.md +9 -0
- happy_engineering_sdk-0.1.0/spec/implementation/complete/1-python-client/.metadata.json +29 -0
- happy_engineering_sdk-0.1.0/spec/implementation/complete/1-python-client/deviations.md +5 -0
- happy_engineering_sdk-0.1.0/spec/implementation/complete/1-python-client/hld.md +75 -0
- happy_engineering_sdk-0.1.0/spec/implementation/complete/1-python-client/lld.md +369 -0
- happy_engineering_sdk-0.1.0/spec/implementation/complete/1-python-client/tasks.md +160 -0
- happy_engineering_sdk-0.1.0/spec/implementation/complete/2-pypi-publish/.metadata.json +1 -0
- happy_engineering_sdk-0.1.0/spec/implementation/complete/2-pypi-publish/deviations.md +24 -0
- happy_engineering_sdk-0.1.0/spec/implementation/complete/2-pypi-publish/hld.md +101 -0
- happy_engineering_sdk-0.1.0/spec/implementation/complete/2-pypi-publish/lld.md +183 -0
- happy_engineering_sdk-0.1.0/spec/implementation/complete/2-pypi-publish/tasks.md +70 -0
- happy_engineering_sdk-0.1.0/spec/infrastructure.md +25 -0
- happy_engineering_sdk-0.1.0/spec/project.md +20 -0
- happy_engineering_sdk-0.1.0/tests/__init__.py +0 -0
- happy_engineering_sdk-0.1.0/tests/test_e2e.py +84 -0
- happy_engineering_sdk-0.1.0/tests/test_encryption.py +185 -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,89 @@
|
|
|
1
|
+
# happy-engineering-sdk
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/happy-engineering-sdk/)
|
|
4
|
+
[](https://pypi.org/project/happy-engineering-sdk/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
Python SDK for controlling Happy agent sessions.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install happy-engineering-sdk
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Credentials
|
|
16
|
+
|
|
17
|
+
The SDK authenticates using a key file and a server URL.
|
|
18
|
+
|
|
19
|
+
**Key file** — download `agent.key` from the Happy dashboard and place it at:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
~/.happy/agent.key
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Server URL** — set the `HAPPY_SERVER_URL` environment variable to your Happy
|
|
26
|
+
server's base URL:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
export HAPPY_SERVER_URL=https://api.happy.engineering
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quick start
|
|
33
|
+
|
|
34
|
+
The simplest usage is `run_task()` — it spawns a session, sends a prompt, waits
|
|
35
|
+
for the agent to finish, and stops the session, all in one call:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import asyncio
|
|
39
|
+
from happy_sdk import HappyClient
|
|
40
|
+
|
|
41
|
+
async def main():
|
|
42
|
+
client = HappyClient()
|
|
43
|
+
session_id = await client.run_task(
|
|
44
|
+
machine_id="my-machine",
|
|
45
|
+
directory="/home/user/project",
|
|
46
|
+
prompt="Summarise this week's PRs",
|
|
47
|
+
)
|
|
48
|
+
print(f"Task complete — session {session_id}")
|
|
49
|
+
|
|
50
|
+
asyncio.run(main())
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Manual lifecycle
|
|
54
|
+
|
|
55
|
+
For finer control, manage the session lifecycle directly:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
import asyncio
|
|
59
|
+
from happy_sdk import HappyClient
|
|
60
|
+
|
|
61
|
+
async def main():
|
|
62
|
+
client = HappyClient()
|
|
63
|
+
|
|
64
|
+
session_id = await client.spawn_session(
|
|
65
|
+
machine_id="my-machine",
|
|
66
|
+
directory="/home/user/project",
|
|
67
|
+
)
|
|
68
|
+
await client.send_message(session_id, "Hello")
|
|
69
|
+
await client.wait_for_turn_completion(session_id)
|
|
70
|
+
await client.stop_session(session_id)
|
|
71
|
+
|
|
72
|
+
asyncio.run(main())
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## API reference
|
|
76
|
+
|
|
77
|
+
All methods are `async` and must be called from an async context.
|
|
78
|
+
|
|
79
|
+
| Method | Signature | Description |
|
|
80
|
+
|--------|-----------|-------------|
|
|
81
|
+
| `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 |
|
|
82
|
+
| `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 |
|
|
83
|
+
| `send_message` | `(session_id, text, permission_mode="yolo")` | Send a message to an active session |
|
|
84
|
+
| `wait_for_turn_completion` | `(session_id, timeout_seconds=300)` | Block until the agent finishes its current turn |
|
|
85
|
+
| `stop_session` | `(session_id)` | Stop a running session |
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -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
|
+
]
|
|
@@ -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()
|
|
@@ -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)
|