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 Code —
claudebinary (override with--claude/BLEMEESD_CLAUDE). - Codex —
codexbinary, version 0.125+ forcodex 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:
- Listens on a Unix socket.
- Lets clients open, drive, interrupt, resume, and close sessions on either backend.
- Translates each backend's native event stream into the unified
agent.*vocabulary (seedocs/agent-events.md). - 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.configfor 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
blemeesdper OS user. Socket perms (0600) are the only access control. - Remote access (TCP/TLS). Use SSH socket forwarding if needed.
- Running
claudeorcodexinteractively (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.subprocesshandles stdio. - One backend subprocess per open session. CC sessions run a
claude -pchild; Codex sessions run acodex mcp-serverchild. - 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_UNIXstream socket. - Framing: UTF-8 newline-delimited JSON. Exactly one JSON object per line.
- Max line size: 16 MiB (configurable). Oversize → connection closed with an
errorframe. - 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:
$BLEMEESD_SOCKET— explicit override, wins everywhere.$XDG_RUNTIME_DIR/blemeesd.sock— typical on Linux user sessions./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/2note: v2 introduces the unifiedagent.*namespace and thebackend/optionsshape onblemeesd.open. There is no backwards-compatible alias forblemees/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_idmust 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 toclaude -p --session-id, which accepts UUIDs only — non-UUIDs surface asspawn_failed. Codex uses its ownthreadId, but for backend-neutrality clients should always generatestr(uuid.uuid4()). The reference client and theblemeesCLI'sopen newalready do this; thes_abc/s_xyzstrings 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.
--barenote: bare mode disables OAuth/keychain auth and requiresANTHROPIC_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 fromoptions.codex.config.--enable <feature>/--disable <feature>— synthesised fromoptions.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:
messageis forwarded verbatim toclaude -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/callwithname:"codex"(first turn) orname:"codex-reply"(subsequent turns) andarguments:{prompt:<string>, threadId:<native id>?, ...static options from §5.4.2}. Ifcontentis an array, text blocks are concatenated into thepromptstring; non-text blocks (images, documents) are rejected withinvalid_messagebecause 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 -pchild; 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/cancelledfor the in-flighttools/callrequest id. Thecodex mcp-serverchild 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 bysession_configured.msg.rollout_path).delete: false(default) → transcript retained for laterresume: 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):
- The writer attached to each session is unhooked immediately so no more frames are pushed to the dead socket.
- 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 emitsagent.result, the daemon gracefully terminates the child (Claude: close stdin and reap; Codex: close stdio and reap). - If no turn is in flight, the subprocess is terminated immediately (SIGTERM → 500 ms → SIGKILL).
- Either way, the session record is detached, not deleted:
connection_id = None,detached_at = now(). It is reapable afterIDLE_TIMEOUT(during which a late-finishing turn will be torn down along with the session). - A new connection may reattach by opening the same
sessionwithresume: true, optionally passinglast_seen_seqto 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/2rename: theblemees/1codesclaude_crashedandoauth_expiredare gone. Crash reporting is unified underbackend_crashed; auth failures (Anthropic OAuth, OpenAI API key missing, ChatGPT login lapsed) are unified underauth_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:
blemeesd.opened(withlast_seq), then- every buffered frame with
seq > last_seen_seq, then - 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.userwhile the backend has not yet emittedagent.resultis rejected witherror{code:"session_busy"}. - Backend stdout / JSON-RPC frames are translated to
agent.*per §5.6 anddocs/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 asbackend_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.cwdor 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.jsonaccess). - 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 Xreplaced 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.cwdor daemon cwd.- Inherit daemon env (carries
OPENAI_API_KEYand 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.sockon 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.usercontent, 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 setenvpersists until reboot; for durable override, add anEnvironmentVariablesblock 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:
HOMEmust be set inEnvironmentVariables; 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/bininstead 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 runclaude auth. - Codex — JSON-RPC
errorwith auth-related code, or stderr patterns indicating missingOPENAI_API_KEY/ lapsed ChatGPT login. The user must runcodex 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.
- Stop accepting new connections.
- Emit
error{code:"daemon_shutdown"}on every live connection. - 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 vialast_seen_seq. - Wait up to
shutdown_grace_sseconds (default 30) for finishing sessions to reach their nextagent.resultand self-terminate. Idle sessions (no turn in flight) are not subject to this wait. - Force phase: SIGTERM every remaining child, 500 ms grace, then SIGKILL stragglers. Bounded by a 5 s budget.
- Close sockets, unlink socket file.
- 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, plusconnection_id/session_idwhere 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 (underdocs/traces/and copied intotests/fixtures/). One row per row of the table indocs/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 scriptedinitialize/tools/list/notifications/codex/event/tools/callresponse sequence.
Coverage applies to both backends:
- Full turn →
agent.result→ nextagent.userworks 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-idvs--resume; Codex:codexvscodex-replywith cachedthreadId). - Unsafe flags (
options.claude.dangerously_skip_permissions,options.codex.config.<refused>) are rejected at theblemeesd.openstage. backend:"unknown"is rejected withunknown_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 unlessclaudeis installed and authenticated. Same scenarios as before:- Turn → text response,
agent.resultseen. - Context preserved across two turns in one connection.
- Close → reattach with
resume: true→ context intact. - Interrupt mid-generation → respawn → continuation works.
- Turn → text response,
requires_codex— skipped unlesscodexis installed andcodex login statusreports 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/2in v0.1. Breaking changes bump toblemees/3. The daemon supports a single protocol version at a time; clients must request the version the daemon advertises inhello_ack.blemees/1is gone — pre-1.0 means no compatibility shims. - Daemon: semver.
0.xunstable; 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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file blemees-0.8.0.tar.gz.
File metadata
- Download URL: blemees-0.8.0.tar.gz
- Upload date:
- Size: 108.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
76e479876b7422481aa29f83861d9fe395865b9be083ba53cefe63ddda4690eb
|
|
| MD5 |
fd3a00bc7ee2c874b88d6d049b44c6fb
|
|
| BLAKE2b-256 |
8b6c79e627c0f806a0ce833ad44169fa5880bedfc60dff6da8da0e8b7cdce16d
|
Provenance
The following attestation bundles were made for blemees-0.8.0.tar.gz:
Publisher:
release.yml on blemees/blemees-daemon
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
blemees-0.8.0.tar.gz -
Subject digest:
76e479876b7422481aa29f83861d9fe395865b9be083ba53cefe63ddda4690eb - Sigstore transparency entry: 1405109860
- Sigstore integration time:
-
Permalink:
blemees/blemees-daemon@ea9e374b5d3bd6aba29880ca37ca6c4f7e223c8c -
Branch / Tag:
refs/tags/v0.8.0 - Owner: https://github.com/blemees
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@ea9e374b5d3bd6aba29880ca37ca6c4f7e223c8c -
Trigger Event:
push
-
Statement type:
File details
Details for the file blemees-0.8.0-py3-none-any.whl.
File metadata
- Download URL: blemees-0.8.0-py3-none-any.whl
- Upload date:
- Size: 84.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0b844b59b0943769083b4c8362928725d1376086589353ca89023337d93953a4
|
|
| MD5 |
b40fe5187cdd66c2e92067457925f562
|
|
| BLAKE2b-256 |
7f37761675076820cdede813c33941c620b8ee98b5161f0f69c5cdf4c4b9b210
|
Provenance
The following attestation bundles were made for blemees-0.8.0-py3-none-any.whl:
Publisher:
release.yml on blemees/blemees-daemon
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
blemees-0.8.0-py3-none-any.whl -
Subject digest:
0b844b59b0943769083b4c8362928725d1376086589353ca89023337d93953a4 - Sigstore transparency entry: 1405110015
- Sigstore integration time:
-
Permalink:
blemees/blemees-daemon@ea9e374b5d3bd6aba29880ca37ca6c4f7e223c8c -
Branch / Tag:
refs/tags/v0.8.0 - Owner: https://github.com/blemees
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@ea9e374b5d3bd6aba29880ca37ca6c4f7e223c8c -
Trigger Event:
push
-
Statement type: