Daemon
Purpose
Define the lifecycle contract for the Clawperator background daemon. The daemon is a long-running Node process that binds the same Express app used by clawperator serve to a Unix domain socket instead of a TCP port.
The daemon also handles transparent proxying for exec, snapshot, screenshot, and the flat action commands such as open, click, type, read, wait, press, back, close, sleep, scroll, scroll-until, wait-for-nav, and read-value.
Sources
- CLI registration and global flag parsing:
apps/node/src/cli/registry.ts,apps/node/src/cli/index.ts - Daemon command implementation:
apps/node/src/cli/commands/daemon.ts - Daemon proxy implementation:
apps/node/src/cli/daemonProxy.ts - Proxied CLI commands:
apps/node/src/cli/commands/execute.ts,apps/node/src/cli/commands/observe.ts,apps/node/src/cli/commands/action.ts - Daemon path and process helpers:
apps/node/src/domain/daemon/lifecycle.ts - Serve app,
/ping, and/version:apps/node/src/cli/commands/serve.ts - CLI version source:
apps/node/src/domain/version/compatibility.ts - Error codes:
apps/node/src/contracts/errors.ts
Commands
All daemon lifecycle commands return JSON on stdout by default. --output pretty pretty-prints the same object.
| Command | Purpose | Success status values |
|---|---|---|
clawperator daemon start [--device <id>] [--operator-package <package>] |
Spawn daemon run as a detached background process and wait for /ping. |
started, already_running |
clawperator daemon stop [--device <id>] |
Send SIGTERM to the PID in the metadata file and remove PID/socket files. |
stopped, not_running |
clawperator daemon status [--device <id>] |
Check /ping, read metadata, call /version, and report state. |
running, not_running |
clawperator daemon restart [--device <id>] [--operator-package <package>] |
Stop, then start. | started, already_running |
Internal command:
| Command | Public support |
|---|---|
clawperator daemon run |
Internal foreground server process. It is registered so daemon start can spawn it, but it is intentionally omitted from help output. |
Device Key And Paths
The daemon lifecycle is keyed by the raw --device value before device resolution. If --device is omitted or blank, the key is default.
Sanitization from sanitizeDaemonKey():
| Raw device value | Daemon key |
|---|---|
| omitted | default |
"" |
default |
192.168.1.1:5555 |
id-MTkyLjE2OC4xLjE6NTU1NQ |
Nonblank device keys use base64url encoding of the raw --device value so similar serials such as host:5555 and host-5555 cannot collide.
Path formulas from apps/node/src/domain/daemon/lifecycle.ts:
| File | Formula |
|---|---|
| socket | ~/.clawperator/daemon/daemon-<daemon_key>.sock |
| PID metadata | ~/.clawperator/daemon/daemon-<daemon_key>.pid |
| log | ~/.clawperator/daemon/daemon-<daemon_key>.log |
| lifecycle lock | ~/.clawperator/daemon/daemon-<daemon_key>.lock |
The daemon directory is created with mode 0700. The PID metadata file is JSON:
{
"pid": 12345,
"startedAt": 1777176000000,
"daemonKey": "id-ZW11bGF0b3ItNTU1NA",
"cliEntryPath": "/Users/<local_user>/src/clawperator/apps/node/dist/cli/index.js",
"rawDeviceId": "emulator-5554"
}
daemon status uses startedAt to compute daemon.uptimeSeconds. Lifecycle commands also use the ownership fields to avoid treating an unrelated reused PID as the managed daemon.
Daemon lifecycle lock files are created next to the PID metadata and socket. A lock file includes the owning process PID so later daemon commands can reclaim a stale lock when that process no longer exists.
Output Shapes
Start
Started:
{
"ok": true,
"daemon": {
"status": "started",
"socketPath": "/Users/<local_user>/.clawperator/daemon/daemon-id-ZW11bGF0b3ItNTU1NA.sock"
}
}
Already running:
{
"ok": true,
"daemon": {
"status": "already_running",
"socketPath": "/Users/<local_user>/.clawperator/daemon/daemon-id-ZW11bGF0b3ItNTU1NA.sock"
}
}
Status
Running:
{
"ok": true,
"daemon": {
"status": "running",
"pid": 12345,
"version": "0.7.9",
"buildIdentity": {
"entryPath": "/Users/<local_user>/src/clawperator/apps/node/dist/cli/index.js",
"mtimeMs": 1777176000000,
"size": 12345
},
"uptimeSeconds": 4,
"socketPath": "/Users/<local_user>/.clawperator/daemon/daemon-id-ZW11bGF0b3ItNTU1NA.sock"
}
}
Not running:
{
"ok": true,
"daemon": {
"status": "not_running",
"socketPath": "/Users/<local_user>/.clawperator/daemon/daemon-id-ZW11bGF0b3ItNTU1NA.sock"
}
}
Stop
Stopped:
{
"ok": true,
"daemon": {
"status": "stopped",
"socketPath": "/Users/<local_user>/.clawperator/daemon/daemon-id-ZW11bGF0b3ItNTU1NA.sock"
}
}
Stop when not running:
{
"ok": true,
"daemon": {
"status": "not_running",
"socketPath": "/Users/<local_user>/.clawperator/daemon/daemon-id-ZW11bGF0b3ItNTU1NA.sock"
}
}
Version Policy
The daemon exposes GET /version on its Unix socket and returns:
{
"version": "0.7.9",
"buildIdentity": {
"entryPath": "/Users/<local_user>/src/clawperator/apps/node/dist/cli/index.js",
"mtimeMs": 1777176000000,
"size": 12345
}
}
The version value comes from getCliVersion() in apps/node/src/domain/version/compatibility.ts. The buildIdentity value is captured when the Node process starts and identifies the CLI entrypoint path, entrypoint mtime, and entrypoint size.
Lifecycle status reports these values. The proxy layer compares the daemon /version response with the current CLI process version and build identity before dispatching. If either value differs, or if the daemon returns invalid version metadata, the proxy stops the old daemon, starts a new daemon, waits up to 3000 ms, and only dispatches after the restarted daemon reports the same version and build identity.
Transparent Proxy
Daemon proxying is active for:
| CLI command | Proxied endpoint | Post-dispatch fallback |
|---|---|---|
clawperator exec <json-or-file> |
POST /execute on the daemon socket |
no |
clawperator snapshot |
synthetic snapshot payload sent to POST /execute |
yes |
clawperator screenshot |
synthetic take_screenshot payload sent to POST /execute |
no |
Flat action commands such as open, click, type, read, wait, press, back, close, sleep, scroll, scroll-until, wait-for-nav, and read-value |
action payload sent to POST /execute |
no |
Dry-run and validation-only exec modes do not need the daemon because they do not dispatch to Android.
If an execution contains take_screenshot with a caller-relative params.path, the command runs direct before daemon startup. This preserves the normal CLI behavior where relative screenshot paths resolve from the caller's current working directory, not from the daemon process working directory.
Proxy selection rules:
- If
--no-daemonorCLAWPERATOR_NO_DAEMON=1is set, the command runs direct. - If the execution includes
take_screenshotwith a relative output path, the command runs direct. - If the platform is Windows, the command runs direct because this task uses Unix domain sockets only.
- If the socket is missing, the proxy serializes daemon startup with the lifecycle lock, spawns
daemon run, polls/pingevery100ms, and waits up to3000ms. - If the socket exists but connection is refused, the proxy deletes the stale socket and starts a new daemon.
- If
/versiondoes not match the current CLI version and build identity, the proxy stops the old daemon and starts a new one. - If the daemon is ready and the version and build identity match, the command sends the execution payload to
POST /execute. - If the daemon cannot become ready before dispatch, the command runs direct for that call.
The proxy sends the caller's effective Operator package in the request body. Precedence is:
- explicit
--operator-package - nonblank
CLAWPERATOR_OPERATOR_PACKAGE com.clawperator.operator
This preserves caller behavior even when an already-running daemon was started with a different environment.
Opt Out
Global placement:
clawperator --no-daemon snapshot --device emulator-5554
Command-local placement:
clawperator snapshot --no-daemon --device emulator-5554
Environment:
CLAWPERATOR_NO_DAEMON=1 clawperator snapshot --device emulator-5554
Success condition for an opt-out verification:
- the command returns the same CLI JSON shape as normal direct execution
clawperator daemon status --device emulator-5554is unchanged by the opt-out command
Dispatch Boundary
Pre-dispatch failures can safely fall back to direct mode because no action was sent to Android.
Post-dispatch failures are different. After the HTTP request body is written to the daemon socket, a lost response may mean the action already executed. Retrying a mutating action can duplicate side effects.
Fallback policy:
| Command category | Post-dispatch response loss behavior |
|---|---|
exec |
return DAEMON_PROXY_ERROR; do not run direct |
| Flat action commands | return DAEMON_PROXY_ERROR; do not run direct |
snapshot |
may run direct once because the synthetic action is read-only |
screenshot |
return DAEMON_PROXY_ERROR; do not run direct because it can write a host output file |
DAEMON_PROXY_ERROR shape:
{
"code": "DAEMON_PROXY_ERROR",
"message": "Daemon response lost; action may have executed",
"details": {
"error": "Error: socket hang up"
}
}
Failure Modes
| Failure | Output code | Exit code | Recovery |
|---|---|---|---|
daemon start spawns daemon run but /ping does not become reachable within 3000 ms |
DAEMON_START_FAILED |
1 |
Inspect ~/.clawperator/daemon/daemon-<daemon_key>.log, then retry clawperator daemon start --device <id>. |
daemon start cannot spawn the background process |
DAEMON_START_FAILED |
1 |
Verify the branch-local CLI entrypoint exists and retry from the same checkout. |
daemon stop cannot signal or clean up the process |
DAEMON_STOP_FAILED |
1 |
Inspect the PID metadata file and socket path, stop the process manually if needed, then retry daemon stop. |
exec or a flat mutating action was dispatched through the daemon but the response was lost |
DAEMON_PROXY_ERROR |
1 |
Do not blindly retry. Inspect device state first because the action may already have executed. |
Top-level daemon failures use the same CLI error shape as other Node-side failures:
{
"code": "DAEMON_START_FAILED",
"message": "Daemon did not become ready within 3000ms.",
"details": {
"socketPath": "/Users/<local_user>/.clawperator/daemon/daemon-id-ZW11bGF0b3ItNTU1NA.sock",
"timeoutMs": 3000
}
}
Verification
Use the installed CLI when validating daemon behavior:
clawperator daemon start --device emulator-5554
clawperator daemon status --device emulator-5554
clawperator daemon stop --device emulator-5554
clawperator daemon status --device emulator-5554
Success conditions:
- start exits
0anddaemon.statusisstartedoralready_running - status while running exits
0,daemon.status == "running",daemon.pidis a number,daemon.versionis a string,daemon.buildIdentity.entryPathis a string, anddaemon.uptimeSeconds >= 0 - stop exits
0anddaemon.statusisstoppedornot_running - final status exits
0anddaemon.status == "not_running"
Verify the internal command is hidden from help:
clawperator --help | grep "daemon run"
Success condition: no matches.