Skip to main content

Headless agent daemon exposing local agent CLIs (Claude Code, Codex) over a Unix socket.

Project description

blemeesd — Headless agent daemon

Version: 0.1 Protocol: blemees/2 Language: Python 3.11+, stdlib only (no runtime deps). Type-hinted. Target OS: Linux, macOS. Windows not supported.

This document is both the README and the authoritative protocol spec. Machine-readable JSON Schemas live under blemees/schemas/ and ship inside the wheel — clients can resolve them via importlib.resources.files("blemees.schemas"). The unified event vocabulary is documented in docs/agent-events.md.


0. Install

Python 3.11+. No runtime dependencies outside the standard library. At least one of the supported agent backends must be on $PATH:

  • Claude Codeclaude binary (override with --claude / BLEMEESD_CLAUDE).
  • Codexcodex binary, version 0.125+ for codex mcp-server (override with --codex / BLEMEESD_CODEX).

The daemon picks the backend per session, on blemeesd.open. A daemon without claude on $PATH can still serve backend:"codex" sessions and vice versa; the missing binary surfaces as a spawn_failed only when a session that needs it is opened.

PyPI is the canonical source — every channel below pulls the same wheel from there.

# pip (any environment):
pip install blemees

# uv (isolated CLI tool, fast):
uv tool install blemees

# pipx (isolated CLI tool, classic):
pipx install blemees

# Homebrew (macOS / Linux):
brew tap blemees/tap
brew install blemees

From source for development:

git clone https://github.com/blemees/blemees-daemon
cd blemees-daemon
uv pip install -e ".[dev]"      # or: pip install -e ".[dev]"

Run in the foreground:

blemeesd                          # socket at $XDG_RUNTIME_DIR/blemeesd.sock
blemeesd --socket /tmp/blemeesd.sock
blemeesd --log-level debug

Socket permissions are 0600. Anyone who can connect() the socket has full access to your Claude subscription, so guard it like an SSH agent.

systemd (Linux user unit)

mkdir -p ~/.config/systemd/user/
cp packaging/blemeesd/blemeesd.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now blemeesd
journalctl --user -u blemeesd -f

launchd (macOS)

cp packaging/blemeesd/com.blemees.blemeesd.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.blemees.blemeesd.plist

brew services (after brew install)

brew services start blemees

The Homebrew formula ships a service stanza so the daemon runs at login without you touching launchd by hand.

Smoke-test the wire (blemees)

The package also ships blemees, an interactive REPL that maps each command to one outbound wire frame and prints every inbound frame. It's not a chat UI — it's how you poke the protocol, sanity-check an install, or reproduce a bug from a known sequence of frames.

$ blemees
· connected: /tmp/blemeesd-501.sock
→ {"type":"blemeesd.hello","client":"blemees-cli/0.1.0","protocol":"blemees/2"}
← blemeesd.hello_ack  {"daemon":"blemeesd/0.1.0","backends":{"claude":"2.1.118","codex":"0.125.0"},…}
blemees> status
← blemeesd.status_reply  {"uptime_s":12.4,"connections":1,…}
blemees> open new backend=claude options.model=sonnet options.permission_mode=bypassPermissions
· session_id: 5a01f0d8-…
← blemeesd.opened  …
blemees> send 5a01f0d8-… what is 2+2?
← agent.delta {"backend":"claude","kind":"text","text":"4"}
← agent.result {"backend":"claude","subtype":"success","duration_ms":…}
blemees> close 5a01f0d8-…

help at the prompt lists every verb. Highlights: open / resume / close / interrupt / send / send-json / watch / unwatch / status / session-info / sessions <cwd> / ping. raw {…} sends an arbitrary JSON frame for protocol experiments.


1. Overview

blemeesd is a per-user daemon that exposes one or more agent backends — currently Claude Code (claude -p) and Codex (codex mcp-server) — as a long-running, multi-session backend over a Unix domain socket. It is a thin, general-purpose wrapper: clients get a headless agent they can reach from any language, any process.

The daemon is a translation layer, not a re-interpreter. It does not inject a system prompt, does not implement a tool protocol, does not filter events. It:

  1. Listens on a Unix socket.
  2. Lets clients open, drive, interrupt, resume, and close sessions on either backend.
  3. Translates each backend's native event stream into the unified agent.* vocabulary (see docs/agent-events.md).
  4. Manages subprocess lifecycle (spawn, kill, respawn or re-attach per-backend).

Clients pick a backend per session via blemeesd.open.backend and pass backend-specific knobs under options.<backend>.*. Any session emits the same agent.* frames regardless of backend — clients can switch on event type without branching by backend.


2. Goals and Non-Goals

Goals (v0.1)

  • Expose Claude Code and Codex over a local Unix socket, multiplexing multiple sessions of either backend.
  • Support each backend's full non-interactive surface relevant to programmatic use (§6). Clients control their own system prompt, tools, model, cwd, etc., via options.<backend>.*.
  • Single unified event vocabulary (agent.*) so clients are backend-agnostic on the read side.
  • Session resume across client disconnects and daemon restarts.
  • Interrupt: cancel the in-flight turn cleanly and allow continuation.
  • Sub-second warm first-event latency; ~1 s cold start (CC).
  • Be neutral on semantics — no client-specific assumptions, no built-in prompts, no tool protocols. The daemon translates event shapes; it does not re-interpret tool calls or model output.

Non-goals (v0.1)

  • Inventing a tool protocol. Clients either use each backend's native tools (CC: options.claude.tools, options.claude.mcp_config, …; Codex: options.codex.config for MCP child servers, sandboxing, approvals) or implement their own protocol in their own system prompt. The daemon does not parse assistant output.
  • Cross-backend session migration (resume a Claude session on Codex, etc.). Each backend owns its own session storage.
  • Multi-user daemons. One blemeesd per OS user. Socket perms (0600) are the only access control.
  • Remote access (TCP/TLS). Use SSH socket forwarding if needed.
  • Running claude or codex interactively (without programmatic stdio).
  • Token refresh. If OAuth expires, surface the error and let the user re-authenticate against the relevant CLI manually.
  • Prompt caching control, GUI/admin interface.

3. Architecture

┌──────────────────────────────────────────────────────────────┐
│ blemeesd (single asyncio event loop)                         │
│                                                              │
│   UnixServer  listens on $XDG_RUNTIME_DIR/blemeesd.sock      │
│      │                                                       │
│      ├─ Connection 1                                         │
│      │    ├─ Session s_abc  → ClaudeBackend (claude -p)      │
│      │    └─ Session s_def  → CodexBackend  (codex mcp-server)│
│      │                                                       │
│      └─ Connection 2                                         │
│           └─ Session s_xyz  → ClaudeBackend (claude -p)      │
│                                                              │
│   AgentBackend (per-session)                                 │
│     - spawns / drives / kills / re-attaches its child         │
│     - translates native events → agent.* frames              │
│                                                              │
│   SessionTable                                               │
│     - session_id → (connection_id?, backend, cwd)            │
│     - reaps orphans after IDLE_TIMEOUT                       │
└──────────────────────────────────────────────────────────────┘
  • Single asyncio event loop. asyncio.subprocess handles stdio.
  • One backend subprocess per open session. CC sessions run a claude -p child; Codex sessions run a codex mcp-server child.
  • The backend object owns the native protocol (CC stream-json line stream vs. Codex JSON-RPC 2.0) and emits unified agent.* frames into the Session.
  • Sessions outlive client connections (reattach via resume: true).
  • Unattached sessions reaped after IDLE_TIMEOUT (default 900 s).

4. File Layout

blemees/
  __init__.py
  __main__.py            # python -m blemees → daemon entry point
  daemon.py              # UnixServer + connection dispatcher
  protocol.py            # wire protocol codec, message dataclasses
  session.py             # SessionTable
  backends/
    __init__.py          # AgentBackend Protocol
    claude.py            # ClaudeBackend (spawn `claude -p`, translate stream-json → agent.*)
    codex.py             # CodexBackend  (spawn `codex mcp-server`, translate JSON-RPC → agent.*)
    translate_claude.py  # CC native event → agent.* frames
    translate_codex.py   # Codex `msg.*` → agent.* frames
  config.py              # config loading (file + env + CLI)
  errors.py              # typed exceptions
  logging.py             # structured logging helpers
  client.py              # reference Python client (~200 lines, stdlib only)
tests/blemees/
  test_protocol.py
  test_session.py
  test_translate_claude.py
  test_translate_codex.py
  test_daemon_mock.py        # mock `claude` and mock `codex mcp-server` stubs
  test_daemon_e2e_claude.py  # requires real `claude`, gated by `requires_claude`
  test_daemon_e2e_codex.py   # requires real `codex`,  gated by `requires_codex`

Package is self-contained (no external imports outside stdlib). A console script blemeesd in pyproject.toml maps to python -m blemees.


5. Wire Protocol

Machine-readable JSON Schemas for every frame in this section live under blemees/schemas/ (Draft 2020-12) and ship as package data in the wheel. See blemees/schemas/README.md for layout and usage; the helpers blemees.schemas.load(name) and iter_schemas() give you a parsed schema or a stream of them without touching the filesystem. This prose is the human-facing spec; the schemas are the contract.

5.1 Framing

  • Transport: AF_UNIX stream socket.
  • Framing: UTF-8 newline-delimited JSON. Exactly one JSON object per line.
  • Max line size: 16 MiB (configurable). Oversize → connection closed with an error frame.
  • Full duplex. Neither side should block on write (see §9.3).

Client socket resolution

Clients using BlemeesClient.connect() (and the daemon itself for its own default) resolve the socket path in this order of precedence, stopping at the first match:

  1. $BLEMEESD_SOCKET — explicit override, wins everywhere.
  2. $XDG_RUNTIME_DIR/blemeesd.sock — typical on Linux user sessions.
  3. /tmp/blemeesd-<uid>.sock — macOS and Linux without XDG.

Only set BLEMEESD_SOCKET in the client's environment when the daemon was started with a non-default path (e.g. via blemeesd --socket …).

5.2 Message namespacing

Every type on the wire carries an explicit namespace prefix:

Prefix Emitted by Purpose
blemeesd.* client → daemon, daemon → client Session lifecycle and daemon operations: hello, hello_ack, open, opened, close, closed, interrupt, interrupted, error, stderr, replay_gap, list_sessions, sessions, ping, pong, status, status_reply, watch, watching, unwatch, unwatched, session_taken, session_info, session_info_reply.
agent.* client → daemon, daemon → client Conversation messages, normalised across backends. Inbound (agent.user) is the client's user turn, which the daemon hands to the backend's native input mechanism (CC: stream-json stdin; Codex: tools/call). Outbound is the daemon's translated event stream: agent.system_init, agent.delta, agent.message, agent.user_echo, agent.tool_use, agent.tool_result, agent.notice, agent.result. Every outbound agent.* frame carries a backend: "claude" | "codex" field; clients that want backend-native fidelity can opt into a raw field per session via options.<backend>.include_raw_events: true. The full type-by-type translation table is in docs/agent-events.md.

Rationale: two stable namespaces — one for session lifecycle, one for the conversation stream in either direction. The unified agent.* namespace lets clients consume Claude and Codex sessions with the same event-handling code; backends only differ in what they accept under options.<backend>.* at open time, not in what they emit on the wire.

5.3 Handshake

Client opens the connection and sends:

{"type":"blemeesd.hello","client":"your-tool/0.1","protocol":"blemees/2"}

Daemon replies:

{
  "type":"blemeesd.hello_ack",
  "daemon":"blemeesd/0.1",
  "protocol":"blemees/2",
  "pid":12345,
  "backends":{
    "claude":"2.1.118",
    "codex":"0.125.0"
  }
}

backends carries one entry per backend the daemon successfully detected at startup. Backends whose binary is missing from $PATH are omitted (so {"backends":{"claude":"2.1.118"}} is a valid ack — Codex is just unavailable). Detection is best-effort and never blocks startup.

If protocol does not match, daemon sends blemeesd.error (code protocol_mismatch) and closes.

blemees/2 note: v2 introduces the unified agent.* namespace and the backend / options shape on blemeesd.open. There is no backwards-compatible alias for blemees/1 — pre-1.0 clients must migrate. See the migration notes at the bottom of §5.

5.4 Session open

A session opens against a chosen backend. The frame envelope is backend-neutral; backend-specific knobs live under options.<backend>.

{
  "type": "blemeesd.open",
  "id": "req_001",
  "session_id": "s_abc",
  "backend": "claude",
  "resume": false,
  "last_seen_seq": 0,

  "options": {
    "claude": {
      "model": "sonnet",
      "system_prompt": "...",
      "tools": "default",
      "permission_mode": "bypassPermissions",
      "cwd": "/home/u/proj",
      "include_raw_events": false
    }
  }
}

Or against Codex:

{
  "type": "blemeesd.open",
  "id": "req_002",
  "session_id": "s_xyz",
  "backend": "codex",
  "resume": false,

  "options": {
    "codex": {
      "model": "gpt-5.2-codex",
      "cwd": "/home/u/proj",
      "sandbox": "read-only",
      "approval-policy": "never",
      "developer-instructions": "...",
      "config": { "model_reasoning_effort": "medium" },
      "include_raw_events": false
    }
  }
}

type, session_id, backend are REQUIRED. options is REQUIRED but may be {} (the backend will then run with its defaults). Only the options.<backend> block matching the chosen backend is consulted — extra blocks for other backends are ignored. Unknown keys inside an options.<backend> block are rejected with invalid_message.

session_id must be a UUID. The daemon treats it as opaque at the protocol layer (any non-empty string passes schema validation), but the Claude backend forwards it to claude -p --session-id, which accepts UUIDs only — non-UUIDs surface as spawn_failed. Codex uses its own threadId, but for backend-neutrality clients should always generate str(uuid.uuid4()). The reference client and the blemees CLI's open new already do this; the s_abc / s_xyz strings throughout this spec are short placeholders for readability, not legal session ids.

5.4.1 options.claude.*

Field CLI flag Notes
model --model <v>
system_prompt --system-prompt <v>
append_system_prompt --append-system-prompt <v>
tools --tools <v> Empty string disables all tools.
disallowed_tools --disallowedTools <v...>
permission_mode --permission-mode <v> One of default, acceptEdits, bypassPermissions, plan.
cwd chdir() before spawn
add_dir --add-dir <v...>
effort --effort <v>
agent --agent <v> CC subagent name. Distinct from the top-level backend selector — this is the nested options.claude.agent.
agents --agents <json> CC subagent config map.
mcp_config --mcp-config <v...>
strict_mcp_config --strict-mcp-config
settings --settings <v>
setting_sources --setting-sources <v>
plugin_dir --plugin-dir <v> (repeated)
betas --betas <v...>
exclude_dynamic_system_prompt_sections --exclude-dynamic-system-prompt-sections
max_budget_usd --max-budget-usd <v>
json_schema --json-schema <v>
fallback_model --fallback-model <v>
session_name -n <v>
session_persistence --no-session-persistence when false
include_partial_messages --include-partial-messages
replay_user_messages --replay-user-messages
include_raw_events n/a — translation-layer flag When true, every agent.* frame the daemon emits for this session carries a raw field with the un-prefixed CC stream-json dict it was translated from. Default false.

Flags the daemon refuses to pass (always rejected with unsafe_flag): --dangerously-skip-permissions, --allow-dangerously-skip-permissions, --bare (see note), --continue, --from-pr. Clients that need bypassPermissions should set permission_mode: "bypassPermissions" explicitly — the daemon allows that, it just refuses the legacy kill switch.

--bare note: bare mode disables OAuth/keychain auth and requires ANTHROPIC_API_KEY. Incompatible with the daemon's typical auth assumption. v0.1 does not support it.

The daemon always passes --verbose --input-format stream-json --output-format stream-json. Clients cannot override these — the event multiplexer requires them.

5.4.2 options.codex.*

These map directly to fields on tools/call arguments for the codex (new session) / codex-reply (continue) MCP tools.

Field Codex tool argument Notes
model model e.g. gpt-5.2-codex.
profile profile Profile name from ~/.codex/config.toml.
cwd cwd Working directory for the session.
sandbox sandbox read-only, workspace-write, or danger-full-access.
approval-policy approval-policy untrusted, on-failure, on-request, never.
base-instructions base-instructions Replaces Codex's default base instructions.
developer-instructions developer-instructions Injected as a developer-role message.
compact-prompt compact-prompt Prompt used when compacting the conversation.
config config Free-form object, deep-merged over ~/.codex/config.toml.
include_raw_events n/a — translation-layer flag When true, every agent.* frame carries the original msg dict from the underlying notifications/codex/event under raw. Default false.

Backend-side process flags also passed by the daemon when relevant:

  • -c <key=value> overrides — synthesised from options.codex.config.
  • --enable <feature> / --disable <feature> — synthesised from options.codex.config.features.<name>.

The daemon does not expose codex login / logout / mcp (the client subcommand) — those manage external state on the user's machine. The codex backend assumes codex login status succeeds; otherwise sessions fail with auth_failed (§5.10).

5.4.3 Reply

Daemon reply on success:

{
  "type":"blemeesd.opened",
  "id":"req_001",
  "session_id":"s_abc",
  "backend":"claude",
  "subprocess_pid":54321,
  "native_session_id":"s_abc",
  "last_seq":0
}

native_session_id is the backend's own session identifier — equal to session_id for the Claude backend (CC's --session-id), and the Codex threadId for the Codex backend. Clients usually don't need it but it appears in transcripts and logs.

On failure:

{"type":"blemeesd.error","id":"req_001","session_id":"s_abc","code":"spawn_failed","message":"..."}

5.5 User message

Client sends a new user turn to an open session. The frame shape is the same regardless of backend; the daemon translates the inner message into the backend's native input shape.

Simple text:

{"type":"agent.user","session_id":"s_abc","message":{"role":"user","content":"Hello"}}

Multimodal (content may be an array of content blocks):

{"type":"agent.user","session_id":"s_abc","message":{"role":"user","content":[{"type":"text","text":"What is in this image?"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"..."}}]}}

message.role must be "user". message.content must be a string or an array of content blocks. Any additional fields on message pass through unchanged; the daemon does not validate them.

Per-backend translation:

  • Claude: message is forwarded verbatim to claude -p's stream-json stdin as {"type":"user","message":<message>,"session_id":<native id>}. Multimodal arrays go through unchanged — CC owns the inner block schema.
  • Codex: the daemon issues a tools/call with name:"codex" (first turn) or name:"codex-reply" (subsequent turns) and arguments:{prompt:<string>, threadId:<native id>?, ...static options from §5.4.2}. If content is an array, text blocks are concatenated into the prompt string; non-text blocks (images, documents) are rejected with invalid_message because Codex's MCP tool surface does not yet accept them. This restriction is documented here; relaxing it is a future protocol addition (agent.user.attachments).

No id required. Responses stream as agent.* events until the turn ends with an agent.result.

5.6 Event stream (daemon → client)

The daemon reads each line of the backend child's stdout, drives the backend's native protocol (CC stream-json or Codex JSON-RPC), and translates each native event into one or more agent.* frames. Frames carry session_id, a monotonic per-session seq, and backend.

The full mapping is locked in docs/agent-events.md. The eight agent.* types are:

  • agent.system_init — first frame after spawn (model, cwd, capabilities).
  • agent.delta — incremental output during a turn (kind: "text" \| "thinking" \| "tool_input").
  • agent.message — a complete assistant message.
  • agent.user_echo — the backend's echo of the user's turn (CC and Codex both emit one).
  • agent.tool_use — a tool invocation request from the model.
  • agent.tool_result — the result the backend received for a tool invocation.
  • agent.notice — informational backend events (rate-limit pings, Codex's MCP-startup chatter, etc.).
  • agent.result — turn-end. Always the last frame for a turn.

Example (abridged) — same shape regardless of backend:

{"session_id":"s_abc","seq":1,"type":"agent.system_init","backend":"claude","model":"claude-sonnet-4-6","tools":["Bash","Read","Edit"]}
{"session_id":"s_abc","seq":2,"type":"agent.delta","backend":"claude","kind":"text","text":"Hel"}
{"session_id":"s_abc","seq":3,"type":"agent.delta","backend":"claude","kind":"text","text":"lo"}
{"session_id":"s_abc","seq":4,"type":"agent.message","backend":"claude","role":"assistant","content":[{"type":"text","text":"Hello"}]}
{"session_id":"s_abc","seq":5,"type":"agent.result","backend":"claude","subtype":"success","duration_ms":1254,"num_turns":1,"usage":{"input_tokens":15,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}

The daemon DOES translate (deduplicate, normalise field names, fold sub-events). Clients that need backend-native fidelity opt in via options.<backend>.include_raw_events: true to get the original event under each frame's raw field.

Events that arrive on the backend child's stderr are wrapped and forwarded as well (for visibility into warnings / auth errors):

{"session_id":"s_abc","type":"blemeesd.stderr","line":"..."}

These are rate-limited to prevent a broken subprocess from flooding the client. Default cap: 50 lines per 10 s; excess dropped with a counter.

5.7 Interrupt

Client cancels the in-flight turn:

{"type":"blemeesd.interrupt","session_id":"s_abc"}

Per-backend mechanism:

  • Claude: the daemon sends SIGTERM to the claude -p child; if it is still alive after 500 ms, SIGKILL. It then respawns the subprocess with --resume <session> (all other flags identical to the original open).
  • Codex: the daemon sends an MCP notifications/cancelled for the in-flight tools/call request id. The codex mcp-server child is not killed — it stays up and ready for the next turn.

Daemon emits:

{"type":"blemeesd.interrupted","session_id":"s_abc"}

Any agent.* events emitted before the cancel are forwarded as normal. Already-sent deltas are NOT retracted. The interrupted turn is closed with agent.result{subtype:"interrupted"}.

Interrupt is a no-op (returns blemeesd.interrupted with was_idle: true) if no turn is in flight.

5.8 Close

Explicit session close:

{"type":"blemeesd.close","id":"req_099","session_id":"s_abc","delete":true}
  • delete: true → daemon removes the backend's transcript file from disk after kill (CC: ~/.claude/projects/<cwd-hash>/<session>.jsonl; Codex: the rollout JSONL pointed at by session_configured.msg.rollout_path).
  • delete: false (default) → transcript retained for later resume: true.

Daemon replies:

{"type":"blemeesd.closed","id":"req_099","session_id":"s_abc"}

5.9 Connection close

When the socket is closed from the client side without explicit close messages (soft detach):

  1. The writer attached to each session is unhooked immediately so no more frames are pushed to the dead socket.
  2. If a turn is in flight, the subprocess is not killed — it keeps running to completion and the session is marked "finishing". Events continue to accumulate in the session's ring buffer (§5.11) and in the durable log if enabled, so a client that reconnects can replay them via last_seen_seq. When the backend next emits agent.result, the daemon gracefully terminates the child (Claude: close stdin and reap; Codex: close stdio and reap).
  3. If no turn is in flight, the subprocess is terminated immediately (SIGTERM → 500 ms → SIGKILL).
  4. Either way, the session record is detached, not deleted: connection_id = None, detached_at = now(). It is reapable after IDLE_TIMEOUT (during which a late-finishing turn will be torn down along with the session).
  5. A new connection may reattach by opening the same session with resume: true, optionally passing last_seen_seq to catch up on anything it missed while disconnected.

Rationale: a hard kill mid-turn left the backend's on-disk transcript in whatever partially-flushed state the kill signal allowed, silently diverging the model's conversation state from what the client last saw. Letting the turn complete closes the transcript cleanly and makes mid-stream reconnects a replay problem, not a consistency problem. The same policy applies to Codex (we don't notifications/cancelled the in-flight call on plain disconnect — only on explicit blemeesd.interrupt).

5.9.1 Session takeover

A second connection may open a session that is currently owned by another live connection (via resume: true). The daemon allows the takeover and notifies the previous owner before switching the writer:

{"type":"blemeesd.session_taken","session_id":"s_abc","by_peer_pid":12345}

After this frame the previous connection stops receiving events for that session; its other sessions (if any) are unaffected and its socket stays open. by_peer_pid reflects the new owner's peer PID from SO_PEERCRED when available, for debugging/audit; it is absent when the kernel or platform does not expose it.

If the ex-owner wants the session back, it may itself send open with resume: true — which will in turn notify the current owner. Ping-pong is the clients' problem; the daemon does not arbitrate.

The new owner's subsequent replay (via last_seen_seq) works as usual — the ring buffer is session-local, not connection-local, so frames emitted while the ex-owner held the writer are still available to the new owner.

5.10 Errors

Errors are blemeesd.error frames with a machine-readable code. The daemon never crashes the process on a per-session error.

{"type":"blemeesd.error","id":"req_001","session_id":"s_abc","code":"backend_crashed","message":"stderr tail: ..."}

Error codes the client must handle:

Code Meaning Fatal to connection?
protocol_mismatch Incompatible protocol version. Yes.
invalid_message Malformed JSON or bad field. No.
unknown_message Unknown blemeesd.* type. No.
unknown_backend blemeesd.open.backend is not a backend the daemon knows. No.
unsafe_flag Client requested a refused flag. No.
session_unknown No such session. No.
session_exists Session id collides on open. No.
session_busy Another turn in flight. No.
spawn_failed Backend binary missing or launch failed. No.
backend_crashed Backend subprocess exited unexpectedly mid-turn (or, for Codex, the JSON-RPC channel returned a transport-level error). No.
auth_failed Backend reports it cannot authenticate (CC: OAuth token expired; Codex: not logged in / OPENAI_API_KEY missing). The daemon does not retry. No.
oversize_message Inbound frame too large. Yes.
slow_consumer Per-connection queue stalled. Yes.
daemon_shutdown Daemon shutting down. Yes.
internal Unexpected daemon error. No.

blemees/2 rename: the blemees/1 codes claude_crashed and oauth_expired are gone. Crash reporting is unified under backend_crashed; auth failures (Anthropic OAuth, OpenAI API key missing, ChatGPT login lapsed) are unified under auth_failed.

5.11 Event stream durability (seq, ring buffer, replay)

Every outbound frame the daemon emits for a session — translated agent.* events and synthetic blemeesd.* frames alike — carries a monotonic integer seq, assigned by the session and starting at 1. blemeesd.opened additionally carries last_seq so a reconnecting client knows the highest seq the session has produced.

Recent frames are retained in two places:

  • In-memory ring buffer, per session, bounded (default 1024; BLEMEESD_RING_BUFFER_SIZE). Always on. Survives client disconnects but not daemon restarts.
  • Durable event log, per session, opt-in (BLEMEESD_EVENT_LOG_DIR). Append-only JSONL at <dir>/<session>.jsonl. On session reopen the ring is seeded from the log's tail, so replay survives daemon restarts. close {delete:true} unlinks the log.

On reconnect, the client may request replay:

{"type":"blemeesd.open","id":"r1","session_id":"s1","resume":true,"last_seen_seq":42}

The daemon delivers, in order:

  1. blemeesd.opened (with last_seq), then
  2. every buffered frame with seq > last_seen_seq, then
  3. live frames.

If the buffer has rolled over past last_seen_seq + 1, a one-shot blemeesd.replay_gap{since_seq, first_available_seq} frame is emitted before the replay so the client can detect the loss:

{"type":"blemeesd.replay_gap","session_id":"s1","since_seq":42,"first_available_seq":71}

Omitting last_seen_seq on reattach replays whatever is currently in the ring. Passing last_seen_seq equal to the session's current seq skips replay and goes straight to live delivery.

5.12 Liveness (ping / pong)

Client:

{"type":"blemeesd.ping","id":"req_1","data":"anything"}

Daemon:

{"type":"blemeesd.pong","id":"req_1","data":"anything"}

data is opaque and echoed verbatim. id is recommended for round-trip correlation. Both fields are optional.

5.13 Status introspection

Client:

{"type":"blemeesd.status","id":"req_2"}

Daemon:

{
  "type":"blemeesd.status_reply","id":"req_2",
  "daemon":"blemeesd/0.1.0","protocol":"blemees/2","pid":12345,
  "uptime_s":127.3,
  "socket_path":"/run/user/1000/blemeesd.sock",
  "backends":{"claude":"2.1.118","codex":"0.125.0"},
  "connections":3,
  "sessions":{
    "total":5,"attached":4,"detached":1,"active_turns":2,
    "by_backend":{"claude":3,"codex":2}
  },
  "config":{
    "ring_buffer_size":1024,"event_log_enabled":false,
    "idle_timeout_s":900,"shutdown_grace_s":30,
    "max_concurrent_sessions":64,"max_line_bytes":16777216
  }
}

No side effects. Forward-compatible: new fields may be added inside sessions / config / backends, and new top-level keys may appear. A backend missing from backends means the daemon could not detect that binary at startup; sessions for it will fail with spawn_failed.

5.14 Watch (subscribe-only observer)

A second connection may subscribe to an existing session's event stream without taking ownership. The owner keeps driving the session; watchers receive the same agent.* events, blemeesd.stderr, blemeesd.error{backend_crashed,auth_failed}, and blemeesd.replay_gap frames the owner does, plus an optional replay on subscribe.

Client:

{"type":"blemeesd.watch","id":"req_3","session_id":"s_abc","last_seen_seq":0}

Daemon (ack, then event stream):

{"type":"blemeesd.watching","id":"req_3","session_id":"s_abc","last_seq":42}

Unknown session → blemeesd.error{code:"session_unknown"}. Multiple connections may watch the same session. Watchers cannot drive: agent.user, blemeesd.interrupt, blemeesd.close, and blemeesd.session_taken remain connection-scoped to the owner.

Unsubscribe:

{"type":"blemeesd.unwatch","id":"req_4","session_id":"s_abc"}

Reply:

{"type":"blemeesd.unwatched","id":"req_4","session_id":"s_abc","was_watching":true}

Watchers are also automatically removed when the connection closes.

5.15 Session info (usage + turn counters)

Query a session's cumulative token usage, turn count, and last-turn snapshot. Side-effect-free.

Client:

{"type":"blemeesd.session_info","id":"req_5","session_id":"s_abc"}

Daemon:

{
  "type":"blemeesd.session_info_reply","id":"req_5","session_id":"s_abc",
  "backend":"claude",
  "native_session_id":"s_abc",
  "model":"claude-sonnet-4-6","cwd":"/home/u/proj",
  "turns":5,
  "last_turn_at_ms":1745000000000,
  "last_turn_usage":{
    "input_tokens":500,"output_tokens":200,
    "cache_read_input_tokens":14000,"cache_creation_input_tokens":0
  },
  "cumulative_usage":{
    "input_tokens":3000,"output_tokens":1200,
    "cache_read_input_tokens":70000,"cache_creation_input_tokens":100
  },
  "context_tokens":14500,
  "attached":true,"subprocess_running":true,
  "last_seq":42
}

The accumulator is maintained from each agent.result event's normalised usage block (see NormalisedUsage in docs/agent-events.md). For Codex sessions the reply also carries cumulative_usage.reasoning_output_tokens. context_tokens is the sum of the last turn's input-side tokens (fresh + cache_read + cache_creation) — compare to the model's context window to gauge headroom.

Persistence: when event_log_dir is enabled, the counters are written to <event_log_dir>/<session>.usage.json on every turn (atomic rename) and reloaded on session reopen, so they survive daemon restarts. Without the durable log they are in-memory only and reset to zero on restart. blemeesd.close {delete:true} also unlinks the sidecar.


6. Backend Management

The daemon spawns one backend subprocess per open session. Backends implement a small AgentBackend Protocol (spawn, send_user_turn, interrupt, close, build_resume_args, list_on_disk_sessions, detect_auth_error) so the dispatcher in §3 stays backend-agnostic. The two implementations differ in argv construction, the wire on the child's stdio, the resume mechanism, and on-disk transcript layout.

Both share the same per-session contract:

  • One turn in flight at a time. A second agent.user while the backend has not yet emitted agent.result is rejected with error{code:"session_busy"}.
  • Backend stdout / JSON-RPC frames are translated to agent.* per §5.6 and docs/agent-events.md.
  • Backend stderr is rate-limited and forwarded as blemeesd.stderr.
  • Auth errors detected on the backend's diagnostic output surface as auth_failed; transport-level crashes as backend_crashed.

6.1 Claude backend (backend:"claude")

6.1.1 Launch invocation

Construct argv dynamically from options.claude.* (§5.4.1). Always included:

claude -p
  --verbose
  --session-id <s>    OR    --resume <s>
  --input-format  stream-json   # fixed by the daemon; not client-settable
  --output-format stream-json   # fixed by the daemon; not client-settable
  [all other flags per §5.4.1 mapping, only when set]

Spawn context:

  • cwd = options.claude.cwd or daemon cwd. Do a real chdir in the child (asyncio.create_subprocess_exec(cwd=...)).
  • Inherit daemon env (carries ANTHROPIC_TOKEN / CLAUDE_CODE_OAUTH_TOKEN / ~/.claude/.credentials.json access).
  • stdin/stdout/stderr = asyncio.subprocess.PIPE.

6.1.2 stdin — feeding user messages

Each client agent.user becomes one line on the subprocess stdin in Claude Code's stream-json input shape. Canonical form for simple text:

{"type":"user","message":{"role":"user","content":"<text>"},"session_id":"<native>"}

content arrays pass through unchanged:

{"type":"user","message":{"role":"user","content":[...content blocks...]},"session_id":"<native>"}

Flush after each line.

6.1.3 stdout — translating native events

The daemon reads stdout line-by-line, parses each line as JSON, runs it through translate_claude to produce one or more agent.* frames, and pushes each to the session. Non-JSON stdout is logged and dropped (should not occur; indicates a CC bug). The daemon tracks the synthetic agent.result to know when the turn has ended.

6.1.4 Interrupt

Per §5.7:

  • subprocess.send_signal(SIGTERM) (proc.terminate() on macOS/Linux).
  • After 500 ms, if proc.returncode is None, proc.kill().
  • Await proc.wait() before respawn.
  • Respawn uses the same stored launch argv from the original open, but with --session-id X replaced by --resume X.

6.1.5 Session file management

Claude Code stores session state at ~/.claude/projects/<cwd-hash>/<session-id>.jsonl. The daemon does not parse these files. On close with delete: true, it removes the specific file.

Optional startup housekeeping: remove session files older than SESSION_RETENTION_DAYS (default 7). Opt-in via config.

6.2 Codex backend (backend:"codex")

6.2.1 Launch invocation

codex mcp-server
  [-c key=value]*       # synthesised from options.codex.config
  [--enable feature]*   # synthesised from options.codex.config.features.<name>=true
  [--disable feature]*  # synthesised from options.codex.config.features.<name>=false

Spawn context:

  • cwd = options.codex.cwd or daemon cwd.
  • Inherit daemon env (carries OPENAI_API_KEY and the ChatGPT-OAuth state under ~/.codex/auth.json).
  • stdin/stdout/stderr = asyncio.subprocess.PIPE.

The daemon performs the MCP initialize handshake immediately after spawn (protocolVersion: "2024-11-05", no client capabilities), waits for the response, sends notifications/initialized, and lists tools once to confirm codex and codex-reply are present. The session_configured event from the first tools/call then drives the synthesised agent.system_init.

6.2.2 Driving turns — tools/call

Each agent.user becomes a JSON-RPC tools/call on the child's stdin. First turn for a session uses the codex tool, subsequent turns the codex-reply tool with the cached threadId:

// First turn
{"jsonrpc":"2.0","id":<n>,"method":"tools/call","params":{
  "name":"codex",
  "arguments":{
    "prompt":"<text>",
    /* options.codex.* — model, profile, cwd, sandbox, approval-policy,
       base-instructions, developer-instructions, compact-prompt, config */
  }
}}

// Continue
{"jsonrpc":"2.0","id":<n>,"method":"tools/call","params":{
  "name":"codex-reply",
  "arguments":{"prompt":"<text>","threadId":"<cached>"}
}}

agent.user.message.content arrays are flattened to a single string by concatenating text blocks (§5.5); non-text blocks are rejected with invalid_message.

The daemon stores the in-flight JSON-RPC id so it can match the final response and drive cancellation.

6.2.3 stdio — translating notifications/codex/event

Each notifications/codex/event notification carries _meta.{requestId,threadId} and a msg.{type,...} body. The daemon runs msg through translate_codex per docs/agent-events.md. The terminal frame is synthesised from the JSON-RPC result (or error) for the originating tools/call, augmented with the preceding task_complete and the last token_count.

The daemon tracks the JSON-RPC response to know the turn has ended.

6.2.4 Interrupt

The daemon sends an MCP cancel:

{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":<n>,"reason":"user_interrupt"}}

The codex mcp-server child stays running and ready for the next agent.user. The interrupted turn produces agent.result{subtype:"interrupted"}.

Codex 0.125.x typically responds to a cancel by emitting a codex/event{type:"turn_aborted"} notification, and frequently does not follow up with a JSON-RPC reply. The daemon finalises the in-flight turn from whichever lands first — the abort event or the JSON-RPC response — and drops late events tagged with the cancelled turn's _meta.requestId so they don't pollute the next turn's stream.

6.2.5 Session file management

Codex writes a per-session rollout JSONL whose path the server reports in session_configured.msg.rollout_path (e.g. ~/.codex/sessions/2026/04/27/rollout-2026-04-27T14-42-22-019d…jsonl). The daemon caches this path and unlinks it on close with delete: true. Date-bucketed enumeration for blemeesd.list_sessions walks ~/.codex/sessions/ recursively (cheap; date-pruned to SESSION_RETENTION_DAYS when discovery is enabled).

6.2.6 Resume caveat (Codex 0.125.x)

In-process resume — same codex mcp-server child, sequential turns via codex-reply with the cached threadId — works as expected.

Cross-process resume is unstable on Codex 0.125.x: opening a fresh codex mcp-server child and calling codex-reply with a prior threadId returns a successful but empty result. The daemon issues codex-reply with the cached id correctly; codex itself does not rehydrate model-side state. Treat resume across daemon restarts / reconnects as best-effort on Codex until upstream fixes it. Claude resume preserves context across reattach; Codex does not.


7. Security

  • Socket path: $XDG_RUNTIME_DIR/blemeesd.sock on Linux. On macOS, which lacks $XDG_RUNTIME_DIR, use /tmp/blemeesd-$UID.sock. Configurable via --socket.
  • Permissions: socket created with mode 0600. If the path exists on startup and is not owned by the current UID, refuse to start.
  • No authentication beyond socket perms. Anyone who can connect() the socket gets full access to every backend the daemon can reach (the user's Claude subscription, ChatGPT/OpenAI account, etc.).
  • No remote access. No TCP listener. For remote use, forward via SSH.
  • Peer identity: the daemon captures SO_PEERCRED (Linux) / LOCAL_PEERCRED (macOS) at connect time and logs peer PID/UID. Informational only; no enforcement in v0.1.
  • Secret handling: options.<backend>.system_prompt / base-instructions / developer-instructions, agent.user content, and event deltas are never logged at INFO+. At DEBUG, bodies are redacted to <redacted N chars>. OAuth / API tokens are never logged.

8. Configuration

Config file (optional): ~/.config/blemeesd/config.toml. CLI flags and env vars override. Env prefix: BLEMEESD_.

Key CLI flag Env var Default
socket_path --socket BLEMEESD_SOCKET $XDG_RUNTIME_DIR/blemeesd.sock
claude_bin --claude BLEMEESD_CLAUDE claude on PATH
codex_bin --codex BLEMEESD_CODEX codex on PATH
log_level --log-level BLEMEESD_LOG_LEVEL info
log_file --log-file BLEMEESD_LOG_FILE stderr
max_line_bytes BLEMEESD_MAX_LINE 16777216
idle_timeout_s BLEMEESD_IDLE_TIMEOUT 900
session_retention_days 7 (0 disables)
max_sessions_per_connection 32
max_concurrent_sessions 64
stderr_rate_lines 50
stderr_rate_window_s 10

A backend whose binary cannot be located at startup is simply omitted from the blemeesd.hello_ack.backends map; the daemon serves whichever backends are available. Sessions for a missing backend fail with spawn_failed at open time.

CLI:

blemeesd [--socket PATH] [--claude PATH] [--codex PATH]
        [--log-level LEVEL] [--log-file PATH]
        [--config FILE] [--version]

v0.1 runs in the foreground only. Use systemd/launchd for background.

8.1 systemd user unit (ship in packaging/blemeesd/blemeesd.service)

[Unit]
Description=Headless agent daemon
After=default.target

[Service]
ExecStart=%h/.local/bin/blemeesd
Restart=on-failure
RestartSec=2s

[Install]
WantedBy=default.target

8.2 launchd plist (ship in packaging/blemeesd/com.blemees.blemeesd.plist)

Standard KeepAlive-on-crash plist with ThrottleInterval=5.

8.3 Service lifecycle

blemeesd is a per-user daemon by design — one instance per UID, one set of upstream agent accounts per instance (the claude and codex CLIs read state out of the user's home directory), socket pinned to that UID. Every install path above registers it with a per-user service manager, not a system one.

macOS — LaunchAgent. brew services start blemees writes ~/Library/LaunchAgents/homebrew.mxcl.blemees.plist and loads it into your GUI session via launchctl. Manual install writes ~/Library/LaunchAgents/com.blemees.blemeesd.plist. Either way it:

  • starts at login, restarts on crash (KeepAlive),
  • stops at logout (a power cycle with no login leaves it off),
  • runs as you, so ~/.claude/ and ~/.codex/ creds and session logs are yours.

Socket: /tmp/blemeesd-<uid>.sock. Inspect: brew services list, launchctl list | grep blemees, tail -f "$(brew --prefix)/var/log/blemees/blemeesd.err.log".

Linux — systemd --user unit. brew services start blemees writes ~/.config/systemd/user/homebrew.blemees.service. Manual install writes ~/.config/systemd/user/blemeesd.service. Either way it:

  • starts when your user manager starts (first login after boot),
  • stops when your last session ends (SSH out, logout),
  • runs as you.

Socket: $XDG_RUNTIME_DIR/blemeesd.sock (= /run/user/<uid>/blemeesd.sock). Inspect: systemctl --user status blemeesd, journalctl --user -u blemeesd -f.

Finding the claude and codex binaries

Services do not inherit your shell's PATH. brew services and systemd --user start with a minimal PATH (/usr/bin:/bin:...) plus whatever the unit file adds. The tap formula extends it to cover ~/.local/bin, ~/bin, and $HOMEBREW_PREFIX/bin, which is where the standalone installers put claude and codex. The symptom when this is wrong is a healthy daemon.start line but every session for the affected backend ending in spawn_failed.

If your claude or codex lives elsewhere (npm global under ~/.nvm/..., a custom path, etc.), override with BLEMEESD_CLAUDE and / or BLEMEESD_CODEX:

  • macOS:
    launchctl setenv BLEMEESD_CLAUDE "$(which claude)"
    launchctl setenv BLEMEESD_CODEX  "$(which codex)"
    brew services restart blemees
    
    launchctl setenv persists until reboot; for durable override, add an EnvironmentVariables block to the plist.
  • Linux:
    systemctl --user edit blemeesd
    # add in the editor:
    #   [Service]
    #   Environment="BLEMEESD_CLAUDE=/full/path/to/claude"
    #   Environment="BLEMEESD_CODEX=/full/path/to/codex"
    systemctl --user restart blemeesd
    

Or bake --claude /full/path/to/claude --codex /full/path/to/codex into the unit's ExecStart / plist ProgramArguments.

Running at boot

You probably do not want this — claude and codex run with whatever privileges the daemon has, and a broader trust boundary means a bigger blast radius. If you need it anyway (e.g. headless server, unattended box), these are the supported paths. Both keep the daemon running as one named user; do not run it as root.

Linux — loginctl enable-linger. Single flag, no code or unit changes:

sudo loginctl enable-linger "$USER"

systemd starts your user manager at boot and keeps your --user units alive regardless of login state. Undo with disable-linger.

macOS — hand-rolled LaunchDaemon with UserName. There is no enable-linger equivalent. sudo brew services start blemees does produce a LaunchDaemon, but it runs as root — do not use it. Instead, stop the user-scope service and install a LaunchDaemon that drops to your user at launch:

brew services stop blemees

Write /Library/LaunchDaemons/com.blemees.blemeesd.plist (owned root:wheel, mode 0644):

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>            <string>com.blemees.blemeesd</string>
  <key>UserName</key>         <string>YOUR_USERNAME</string>
  <key>ProgramArguments</key>
  <array>
    <string>/opt/homebrew/bin/blemeesd</string>
  </array>
  <key>EnvironmentVariables</key>
  <dict>
    <key>HOME</key> <string>/Users/YOUR_USERNAME</string>
    <key>PATH</key> <string>/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
  </dict>
  <key>RunAtLoad</key>        <true/>
  <key>KeepAlive</key>        <true/>
  <key>StandardOutPath</key>  <string>/Users/YOUR_USERNAME/Library/Logs/blemees/blemeesd.out.log</string>
  <key>StandardErrorPath</key><string>/Users/YOUR_USERNAME/Library/Logs/blemees/blemeesd.err.log</string>
</dict>
</plist>

Load and unload:

sudo launchctl bootstrap system /Library/LaunchDaemons/com.blemees.blemeesd.plist
sudo launchctl bootout   system /Library/LaunchDaemons/com.blemees.blemeesd.plist

Gotchas:

  • HOME must be set in EnvironmentVariables; LaunchDaemons start with an empty env and ~/.claude/ lookups will fail otherwise.
  • On FileVault-encrypted disks, "at boot" actually means "at first unlock of the disk at boot" — not truly pre-login.
  • Intel Macs: use /usr/local/bin instead of /opt/homebrew/bin.

9. Error Handling and Recovery

9.1 Backend crash mid-turn

On EOF on the child's primary stream (CC stdout closed, or Codex stdio closed before responding) or a non-zero exit during a turn:

{"type":"blemeesd.error","session_id":"s_abc","code":"backend_crashed","message":"<stderr tail>"}

Session remains open. Next agent.user respawns the child (Claude: relaunches claude -p --resume <s>; Codex: relaunches codex mcp-server and replays via codex-reply with the cached threadId).

9.2 Auth failure

Each backend has its own detection signatures:

  • Claude — patterns in stderr: 401, OAuth token expired, Please run claude auth, Session authentication failed. The user must run claude auth.
  • Codex — JSON-RPC error with auth-related code, or stderr patterns indicating missing OPENAI_API_KEY / lapsed ChatGPT login. The user must run codex login.

Either surfaces as:

{"type":"blemeesd.error","session_id":"s_abc","code":"auth_failed","backend":"claude","message":"Run `claude auth` to re-authenticate."}

Do not retry automatically. Subsequent user messages repeat the error until the user re-auths and the daemon sees a successful spawn / turn.

9.3 Backpressure

Bounded per-connection event queue (default 1024). When full, pause reading from the subprocess until the queue drains. If blocked > 30 s, emit error{code:"slow_consumer"} and close the connection. Sessions stay alive, detached, subject to idle timeout.

9.4 Malformed client message

Reply error{code:"invalid_message"}, continue connection. Do not kill sessions.

9.5 Daemon shutdown (SIGINT/SIGTERM)

Shutdown applies the same soft-detach policy as a client disconnect (§5.9): sessions with an in-flight turn are allowed to run to the next agent.result before being terminated, so their transcripts close cleanly.

  1. Stop accepting new connections.
  2. Emit error{code:"daemon_shutdown"} on every live connection.
  3. For every session with turn_active=True, set _finishing=True. Events continue to accumulate in the ring buffer and (if enabled) durable log, so a client that reconnects to a restarted daemon can replay them via last_seen_seq.
  4. Wait up to shutdown_grace_s seconds (default 30) for finishing sessions to reach their next agent.result and self-terminate. Idle sessions (no turn in flight) are not subject to this wait.
  5. Force phase: SIGTERM every remaining child, 500 ms grace, then SIGKILL stragglers. Bounded by a 5 s budget.
  6. Close sockets, unlink socket file.
  7. Exit 0.

Overall wall-clock budget is therefore shutdown_grace_s + 5 s. Past that, the daemon force-exits 1.

Set shutdown_grace_s=0 (via BLEMEESD_SHUTDOWN_GRACE env or config) to disable the graceful phase and hard-kill immediately.

9.6 Stale socket file on startup

  • connect() succeeds → another daemon is running; exit 1 with message.
  • connect() fails → stale; unlink and continue.

10. Logging

  • Structured JSON logs, one object per line, to stderr by default.
  • Every line has ts, level, event, plus connection_id / session_id where applicable.
  • Never log system_prompt / base-instructions / developer-instructions, agent.user.message.content, event deltas, or stderr subprocess output bodies at INFO+. At DEBUG, redact to <redacted N chars>.
  • INFO events to include:
    • daemon start/stop (socket path, detected backends + versions, pid)
    • connection open/close (peer pid, uid)
    • session open/close (backend, model, resume flag — NOT prompts)
    • subprocess spawn/exit (backend, pid, exit code)
    • error frames emitted
    • interrupt received

11. Testing Requirements

11.1 Unit (no backends required)

  • test_protocol.py: encode/decode every message type; malformed inputs; oversize frames; UTF-8 edge cases (surrogate pairs, NUL bytes).
  • test_session.py: session table lifecycle; idle-timeout reaper; reattach by session id; delete-on-close.
  • test_translate_claude.py, test_translate_codex.py: pure translator tests against fixture frames captured from real backends (under docs/traces/ and copied into tests/fixtures/). One row per row of the table in docs/agent-events.md.

11.2 Mock-backend tests

Two stub binaries:

  • fake_claude.py — reads stream-json on stdin and emits scripted stream-json events on stdout.
  • fake_codex.py — speaks JSON-RPC 2.0 on stdio with a scripted initialize / tools/list / notifications/codex/event / tools/call response sequence.

Coverage applies to both backends:

  • Full turn → agent.result → next agent.user works again.
  • Crash mid-turn → backend_crashed, next turn respawns.
  • Interrupt → backend-appropriate cancel observed, continues.
  • Concurrent sessions (3 parallel, mixed backends) do not interfere.
  • Resume mapping is correct (CC: --session-id vs --resume; Codex: codex vs codex-reply with cached threadId).
  • Unsafe flags (options.claude.dangerously_skip_permissions, options.codex.config.<refused>) are rejected at the blemeesd.open stage.
  • backend:"unknown" is rejected with unknown_backend.

11.3 End-to-end tests

Two pytest marks, applied separately so a developer can run only the backends installed on the machine:

  • requires_claude — skipped unless claude is installed and authenticated. Same scenarios as before:
    • Turn → text response, agent.result seen.
    • Context preserved across two turns in one connection.
    • Close → reattach with resume: true → context intact.
    • Interrupt mid-generation → respawn → continuation works.
  • requires_codex — skipped unless codex is installed and codex login status reports logged in. Same scenarios.

11.4 Latency benchmarks (python -m blemees.bench --backend {claude,codex})

Acceptance targets on an ordinary dev machine:

  • Claude: cold open → first event ≤ 1.5 s, warm user → first event ≤ 0.5 s, resume open → first event ≤ 1.5 s.
  • Codex: initialize handshake adds a fixed cost. Warm-user → first delta target is ≤ 1.0 s; cold open + initialize budget is documented empirically rather than gated.

12. Versioning

  • Protocol: blemees/2 in v0.1. Breaking changes bump to blemees/3. The daemon supports a single protocol version at a time; clients must request the version the daemon advertises in hello_ack. blemees/1 is gone — pre-1.0 means no compatibility shims.
  • Daemon: semver. 0.x unstable; breaking changes allowed pre-1.0.

Appendix A: Reference client example

The same event-loop body works for either backend — the only thing that changes is the backend selector and the matching options block.

import asyncio, uuid
from blemees.client import BlemeesClient

async def stream_one_turn(backend: str, options: dict, prompt: str) -> None:
    async with BlemeesClient.connect() as c:
        async with c.open_session(
            session_id=str(uuid.uuid4()),
            backend=backend,
            options={backend: options},
        ) as sess:
            await sess.send_user(prompt)
            async for event in sess.events():
                t = event.get("type")
                if t == "agent.delta" and event.get("kind") == "text":
                    print(event["text"], end="", flush=True)
                elif t == "agent.result":
                    print()
                    break
                elif t == "blemeesd.error":
                    raise RuntimeError(event["message"])

async def main():
    await stream_one_turn(
        "claude",
        {
            "model": "sonnet",
            "system_prompt": "You are a terse assistant. Answer in one sentence.",
            "tools": "",
            "permission_mode": "bypassPermissions",
            "cwd": "/home/u/proj",
        },
        "What is 2+2?",
    )
    await stream_one_turn(
        "codex",
        {
            "model": "gpt-5.2-codex",
            "sandbox": "read-only",
            "approval-policy": "never",
            "cwd": "/home/u/proj",
        },
        "What is 2+2?",
    )

asyncio.run(main())

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

blemees-0.8.2.tar.gz (132.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

blemees-0.8.2-py3-none-any.whl (113.7 kB view details)

Uploaded Python 3

File details

Details for the file blemees-0.8.2.tar.gz.

File metadata

  • Download URL: blemees-0.8.2.tar.gz
  • Upload date:
  • Size: 132.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for blemees-0.8.2.tar.gz
Algorithm Hash digest
SHA256 f5e71defe9acc3e1c768e6b72bc1bfa824935e696a7e36da22bd91eed9c2d5b5
MD5 b7d96cde781cd06767a733406669764c
BLAKE2b-256 2869e5f7b2f5d4154608b41f0f59c61b5e325b9c0ab224f2bef416323015e6a9

See more details on using hashes here.

Provenance

The following attestation bundles were made for blemees-0.8.2.tar.gz:

Publisher: release.yml on blemees/blemees-daemon

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file blemees-0.8.2-py3-none-any.whl.

File metadata

  • Download URL: blemees-0.8.2-py3-none-any.whl
  • Upload date:
  • Size: 113.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for blemees-0.8.2-py3-none-any.whl
Algorithm Hash digest
SHA256 92a2c2f8f35fa4ce3b4975c1317d99194464315d3e559809be39b26e523fcfc8
MD5 4869ac614e09d2a9a9368e7bd672c230
BLAKE2b-256 c4d87378cc0746b5209d396dcaefb75ccef49ed5d0752070ccb24b31570bbcaf

See more details on using hashes here.

Provenance

The following attestation bundles were made for blemees-0.8.2-py3-none-any.whl:

Publisher: release.yml on blemees/blemees-daemon

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page