Daemon Module
Module goal
src/daemon turns the xacpx console into a background process that can be started, queried, and stopped.
It solves four problems:
- Start the background process — launch the console in detached mode.
- Record running state — write PID, status file, and log paths.
- Provide observability — let
xacpx statusknow whether the daemon is actually alive. - Stop the process safely — terminate the daemon per platform and clean up runtime files.
In one sentence: this is the background process lifecycle management layer — not a business message processing layer and not a CLI argument parsing layer.
Runtime files
All runtime file paths are centralized in daemon-files.ts — nothing else computes these paths. Source: src/daemon/daemon-files.ts
| File | Content |
|---|---|
~/.xacpx/runtime/daemon.pid | PID of the running daemon |
~/.xacpx/runtime/status.json | Start time, heartbeat time, config path, state path, log paths |
~/.xacpx/runtime/stdout.log | Daemon stdout |
~/.xacpx/runtime/stderr.log | Daemon stderr |
~/.xacpx/runtime/app.log | Structured application log (bounded / rolling) |
Module files
| File | Responsibility |
|---|---|
daemon-controller.ts | External control surface — start(), getStatus(), stop() |
create-daemon-controller.ts | Controller factory — platform-specific spawn and terminate implementations |
daemon-runtime.ts | In-process registration — writes PID, status file, and heartbeat |
daemon-files.ts | Runtime file path definitions — the single source of truth for all paths |
daemon-status.ts | Status file persistence — read / write / clear status.json; defines DaemonStatus shape |
Start lifecycle
xacpx start sequence:
src/cli.tscreates the daemon controller.- The controller reads the current PID and status, confirming the daemon is not already running.
- The controller spawns a new background process in detached mode.
- The new process enters
run-console.ts. run-console.tscallsdaemonRuntime.start(), writing the PID and status files.- During the main loop,
daemonRuntime.heartbeat()updates the timestamp periodically. - The controller polls
status.jsonin the foreground until it sees the new process's PID reported as ready. - The CLI reports "started" to the user.
Control vs. runtime split: daemon-controller.ts manages external control; daemon-runtime.ts manages in-process self-registration. The two sides communicate only through the shared runtime files — there is no direct call from one to the other.
Status lifecycle
xacpx status uses three signals to determine daemon liveness:
- PID file — tells us which process was last started.
- Process existence — tells us whether that PID is still running.
- Status file — tells us whether the daemon completed self-registration.
The resulting states:
| State | Condition |
|---|---|
running | PID file exists, process exists, valid status file present |
stopped | No PID file, or no status information |
stale stopped | Stale PID or status left by a crashed daemon — controller cleans up |
DaemonController.getStatus() does not just check files — it performs a liveness check on the actual process. This is its core value: a file's existence does not guarantee the process is alive. Source: src/daemon/daemon-controller.ts
Stop lifecycle
xacpx stop sequence:
- Controller reads the PID file.
- If the process is alive, terminates it using the platform-appropriate method.
- Polls until the process has exited.
- Cleans up the PID and status files.
- Returns the stop result.
Platform-specific spawn and terminate behavior is encapsulated in create-daemon-controller.ts, keeping the control logic in daemon-controller.ts platform-agnostic.
Testing notes
- The daemon module sits at the boundary of the process model, so most tests are integration-level: spawn a real subprocess, poll the status file, then stop it.
- Tests that write runtime files must use
mkdtempfor isolation and clean up withrm -rf. - Use
xacpx statusoutput rather than directly readingstatus.jsonto verify daemon state — that's the same path the user takes and exercises the full status logic. - Do not use
Bun.sleep()as a synchronization barrier when waiting for daemon readiness; pollstatus.jsonwith auntilloop or use the promise returned by the controller's polling logic.
Extending the daemon module
Follow this order when adding new daemon capabilities:
- Determine whether the new requirement is a control-plane capability (start/stop/liveness) or a runtime capability (what the daemon does while running).
- Control-plane changes →
daemon-controller.ts. - New in-process self-registration information →
daemon-runtime.ts+daemon-status.ts. - New runtime files →
daemon-files.tsfirst (centralize the path). - Platform differences →
create-daemon-controller.ts.
If a change touches startup strategy, status file shape, stop strategy, and platform compatibility simultaneously, separate the control-plane changes from the runtime changes before writing code.
Code that belongs here:
- Daemon start / stop control logic.
- Runtime file path definitions.
- PID / status / heartbeat management.
- Cross-platform process termination and detached spawn.
Code that does not belong here:
- CLI argument parsing.
- WeChat message polling and processing.
- Agent or session business logic.
- Interpretation of config file contents.