Metadata-Version: 2.5
Name: daimon-sdk
Version: 0.10.0
Summary: Typed async Python SDK for daimon MCP services.
Author: processd contributors
License: MIT
Requires-Python: >=3.12
Requires-Dist: fastmcp<4,>=3.1.1
Requires-Dist: httpx<1,>=0.28
Requires-Dist: opentelemetry-api<2,>=1.27
Provides-Extra: dev
Requires-Dist: opentelemetry-sdk<2,>=1.27; extra == 'dev'
Requires-Dist: pytest-asyncio<1,>=0.24; extra == 'dev'
Requires-Dist: pytest<9,>=8.3; extra == 'dev'
Requires-Dist: pyyaml<7,>=6; extra == 'dev'
Description-Content-Type: text/markdown

# daimon-sdk

Typed async Python SDK for `processd-mcp` and `processd-sandbox-manager`.

`daimon-sdk` wraps the raw MCP tool surface exposed by `processd-mcp` and presents it as grouped Python APIs such as `client.files.read()` and `client.exec.start_session()`. The SDK keeps the monorepo's `core` project as the contract source of truth and focuses on:

- connection and token wiring
- manager sandbox lifecycle helpers
- typed request/response handling
- structured tool error mapping
- interactive session helpers
- compatibility tests against a real `processd-mcp` binary

## Install

```bash
pip install daimon-sdk
```

For local development:

```bash
pip install -e ".[dev]"
```

## Quickstart

With a sandbox manager:

```python
import asyncio

from daimon_sdk import DaimonManagerClient


async def main() -> None:
    async with DaimonManagerClient("http://127.0.0.1:18080") as manager:
        async with manager.sandbox() as sandbox:
            runtime = await sandbox.runtime.get_context()
            print(runtime.base_workdir)

            result = await sandbox.exec.bash("python3 --version")
            print(result.stdout or result.stderr)


asyncio.run(main())
```

### Using a processd scheduler

`processd-scheduler` is wire-compatible with the manager API, so use the same
`DaimonManagerClient` and point `base_url` at any scheduler replica. No
separate scheduler client is required:

```python
async with DaimonManagerClient(
    "https://processd.example.com",
    access_token="cluster-control-token",
) as manager:
    sandbox = await manager.create_sandbox()
    print(sandbox.id)
```

Scheduler sandbox IDs are signed, opaque routing tokens. Store and pass them
back unchanged; do not parse them or substitute a manager-local ID.
The SDK constructs MCP and service-port URLs from the client's `base_url`
and sandbox ID, regardless of the server's advertised URL. Exec and file
requests therefore use the same proxy entrypoint, including with older
schedulers advertising an unreachable address. Creating a sandbox and then
connecting to a server-selected direct address is no longer supported.
Proxy requests continue to use the sandbox's own token exposed by
`SandboxInfo`/`ServicePortInfo`. `SandboxInfo.raw_payload` retains the original
server response; standalone `DaimonClient(url)` connections remain explicit.

If you already have a sandbox MCP URL and token, connect directly to MCP:

```python
import asyncio

from daimon_sdk import DaimonClient


async def main() -> None:
    async with DaimonClient("http://127.0.0.1:8080/mcp") as client:
        runtime = await client.runtime.get_context()
        print(runtime.base_workdir)

        result = await client.files.glob("**/*.rs", path=runtime.base_workdir)
        print(result.filenames[:5])

        bash = await client.exec.bash("printf 'hello from processd\\n'")
        print(bash.stdout)


asyncio.run(main())
```

## Raw MCP vs SDK

Raw MCP:

```python
payload = await mcp_client.call_tool("Read", {"file_path": "/tmp/demo.txt"})
```

SDK:

```python
read = await client.files.read("/tmp/demo.txt")
print(read.file.content)
```

## API Overview

- `DaimonClient(base_url, access_token=None, timeout_s=30.0)`
- `await client.connect()` / `await client.close()`
- `async with DaimonClient(...) as client`
- `client.runtime.get_context()`
- `client.files.read() / write() / edit() / glob() / grep()`
- `client.exec.bash() / start_session()`
- `SessionHandle.write() / poll() / wait_for_exit() / close()`
- `client.web.fetch()`
- `client.raw.call_tool()`
- `DaimonManagerClient(base_url, access_token=None, timeout_s=30.0)`
- `await manager.health()`
- `await manager.capacity()`
- `await manager.create_sandbox()`
- `await manager.list_sandboxes(labels=..., states=...)`
- `await manager.find_sandbox(labels={"thread_id": thread_id})`
- `await manager.find_or_create_sandbox(labels={"thread_id": thread_id})`
- `await manager.get_sandbox(id)`
- `await manager.delete_sandbox(id)`
- `await manager.update_sandbox(id, ttl_seconds=...)`
- `async with manager.sandbox() as sandbox`
- `await sandbox.set_ttl(ttl_seconds)`
- `sandbox.service_ports`
- `sandbox.get_service_port(port)`
- `sandbox.runtime/files/exec/web/raw`

`manager.sandbox()` creates a sandbox, connects to its MCP endpoint, and deletes
it on context exit by default. Use `delete_on_exit=False` or
`create_sandbox()` when the workspace should survive beyond the context.
`find_sandbox(labels=...)` is a read-only lookup by default and does not extend
the sandbox TTL. Pass `ttl_seconds=...` only when you intentionally want the
manager's compatibility renew behavior, or use `find_or_create_sandbox(...)`
for normal acquire/renew flows.
`ttl_seconds=0` marks a sandbox as immediately expired so the next manager
reaper loop deletes it.

On Kubernetes, creation is synchronous: the server reserves a prewarmed Pod,
activates it once, and returns HTTP 201 only when running (200 for an existing
find-or-create match). Activation has a fixed 30-second deadline; the manager client defaults to a
45-second HTTP timeout. Interrupted or
ambiguous creation fails and its Pod is discarded; the SDK does not poll or
resume it. Explicitly create again after failure. There is no stop/start API:
each sandbox ID is single-use. Deletion may return while the Pod is terminating
(`deleting`); completion means the Pod is absent. Empty stock/capacity returns
429, and unavailable/recovering authority returns 503. Redis ledger loss invalidates
all old IDs and discards the old pool epoch, while ordinary manager restarts
preserve running sandboxes. Upgrade SDK, manager and sandbox images together.

## Sandbox Service Ports

When a sandbox runs a service on a manager-configured port, the SDK exposes the
connection information and leaves the request itself to your HTTP or WebSocket
client:

```python
import httpx

async with manager.sandbox() as sandbox:
    web = sandbox.get_service_port(3000)

    response = httpx.get(web.url + "hello.txt", headers=web.headers)
    print(response.text)
```

`ServicePortInfo` contains only `port`, `url`, `token`, and `headers`.

## Local Testing

The SDK compatibility tests use the sibling `core` project in this monorepo:

```text
processd/
  core/
  sdk/
```

Run tests through the repository's VS Code tasks: `test:quick` covers SDK unit and tracing tests, `test:k8s` covers the Kubernetes manager and scheduler E2E, and `test:all` also covers the direct MCP SDK E2E. The direct E2E suite builds and launches `../core/target/debug/processd-mcp`. Kubernetes E2E builds both images, deploys them to an isolated namespace in an existing kind cluster, verifies the SDK lifecycle and NetworkPolicy, then removes the namespace. The cluster must use a CNI that enforces Kubernetes NetworkPolicy. See the [repository test task guide](../README.md#tests-in-vs-code) for prerequisites.

## Release

Releases are published from GitHub Actions when a tag matching `v*` is pushed.

```bash
git tag v0.4.0
git push origin v0.4.0
```

The tag version must match `pyproject.toml`'s project version.
