Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 15 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,29 +62,32 @@ curl -fsSL https://raw.githubusercontent.com/zarvis-ai/agentd/main/install.sh |
Pin a version or change the directory with `CONSTRUCT_VERSION=v0.2.0` /
`CONSTRUCT_BIN_DIR=/usr/local/bin`.

### 3. Start the daemon
### 3. Open the fleet TUI

```sh
construct daemon run
construct
```

Leave this running. It owns sessions, persists state, and exposes the local IPC
socket used by clients. (`constructd` is a back-compat alias for the same
daemon — `constructd run` and `construct daemon run` are equivalent.)
If no daemon is running yet, `construct` auto-starts one in the background and
attaches — there's no separate daemon step. (Opt out with
`CONSTRUCT_NO_AUTOSTART=1`, e.g. in scripts that manage the daemon themselves.)

### 4. Open the fleet TUI
Use `?` for help and `M-x` for the command palette. From the TUI you can create
sessions, switch between agents, send input, inspect diffs, and interrupt or stop
work without leaving the flow.

In a second shell:
To run the daemon explicitly instead (e.g. on a server, or under a process
supervisor):

```sh
construct
construct daemon run
```

Use `?` for help and `M-x` for the command palette. From the TUI you can create
sessions, switch between agents, send input, inspect diffs, and interrupt or stop
work without leaving the flow.
It owns sessions, persists state, and exposes the local IPC socket used by
clients. (`constructd` is a back-compat alias — `constructd run` and
`construct daemon run` are equivalent.)

### 5. Start crack the matrix
### 4. Start crack the matrix

Happy hacking. Chase the dream idea from your terminal: ask Codex, Claude Code,
Antigravity, and [smith](docs/smith.md) to dive into the hard parts, then keep
Expand Down
42 changes: 41 additions & 1 deletion crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -391,12 +391,52 @@ async fn main() -> Result<()> {
async fn connect(socket: &std::path::Path) -> Result<std::sync::Arc<Client>> {
Client::connect(socket).await.with_context(|| {
format!(
"connect to daemon at {} (is `agentd` running?)",
"connect to daemon at {} (start one with `construct daemon run`)",
socket.display()
)
})
}

async fn run_tui(socket: PathBuf) -> Result<()> {
// The TUI is the default `construct` entry point, so make it "just work"
// when no daemon is running yet: auto-start one in the background.
ensure_daemon_running(&socket).await;
app::run_with_socket(socket).await
}

/// Ensure a daemon is listening on `socket`, auto-starting one in the
/// background if not. Best-effort: on any failure we fall through and let
/// `run_with_socket`'s own connect surface the original error. Set
/// `CONSTRUCT_NO_AUTOSTART=1` to opt out (e.g. scripts that manage the daemon
/// themselves). Concurrent auto-starts are safe — the daemon's single-instance
/// lock lets only one survive.
async fn ensure_daemon_running(socket: &std::path::Path) {
use std::time::Duration;

if socket_is_live(socket) {
return;
}
if std::env::var("CONSTRUCT_NO_AUTOSTART").as_deref() == Ok("1") {
return;
}
if let Err(e) = agentd::spawn_detached_daemon(Some(socket)) {
tracing::warn!(error = %e, "failed to auto-start construct daemon");
return;
}
tracing::info!(socket = %socket.display(), "no daemon running; auto-started one");

// The daemon binds the socket early in startup; poll for readiness (~5s).
for _ in 0..50 {
tokio::time::sleep(Duration::from_millis(100)).await;
if socket_is_live(socket) {
return;
}
}
tracing::warn!(socket = %socket.display(), "auto-started daemon not ready yet; continuing");
}

/// Cheap readiness probe: can we open the IPC socket? A stale socket file
/// (the daemon is gone) fails to connect, so this correctly reports "not live".
fn socket_is_live(socket: &std::path::Path) -> bool {
std::os::unix::net::UnixStream::connect(socket).is_ok()
}
1 change: 1 addition & 0 deletions crates/daemon/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ uuid.workspace = true
chrono.workspace = true
toml.workspace = true
nix.workspace = true
libc = "0.2"
which.workspace = true
futures.workspace = true
base64.workspace = true
Expand Down
124 changes: 122 additions & 2 deletions crates/daemon/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,35 @@ pub async fn run(socket_override: Option<PathBuf>) -> Result<()> {
std::fs::create_dir_all(&paths.runtime_dir).ok();
std::fs::create_dir_all(&paths.config_dir).ok();

// Resolve the socket path early so the single-instance lock can key off
// it: two daemons on the *same* socket must not coexist (the second would
// unlink and steal the first's socket in `server::serve`), while daemons
// on *different* sockets are independent.
let socket_path = socket_override.unwrap_or_else(|| paths.socket());

// Single-instance guard. Auto-start (a client spawning the daemon when it
// finds no live socket — see `spawn_detached_daemon`) means two `construct`
// launches can race to start a daemon. Whoever wins this exclusive
// advisory lock binds the socket; the loser exits cleanly instead of
// stealing the socket. Held for the process lifetime — dropped on exit,
// and released across the restart `exec()` (the fd is CLOEXEC) so the new
// image re-acquires it.
let _singleton = match acquire_singleton_lock(&socket_path) {
Ok(Some(guard)) => guard,
Ok(None) => {
tracing::info!(
socket = %socket_path.display(),
"another construct daemon already owns this socket; exiting"
);
eprintln!(
"construct: a daemon already owns {}; not starting another.",
socket_path.display()
);
return Ok(());
}
Err(e) => return Err(e.context("acquire daemon single-instance lock")),
};

let config = config::Config::load_or_default(&paths)?;
tracing::info!(
adapters = config.adapters.len(),
Expand Down Expand Up @@ -124,8 +153,6 @@ pub async fn run(socket_override: Option<PathBuf>) -> Result<()> {
});
}

let socket_path = socket_override.unwrap_or_else(|| paths.socket());

// Always expose the browser UI on localhost without remote-control
// credentials. This is intentionally local-only: `/remote-control`
// remains the opt-in public tunnel path and still layers token +
Expand Down Expand Up @@ -270,6 +297,99 @@ pub async fn run(socket_override: Option<PathBuf>) -> Result<()> {
}
}

/// RAII holder for the daemon single-instance advisory lock. The lock is
/// released when this is dropped (process exit) or when the fd is closed
/// (across the restart `exec()` — the fd is CLOEXEC by default).
pub struct SingletonGuard {
_file: std::fs::File,
}

/// Try to acquire the exclusive single-instance lock for `socket_path`. The
/// lock file sits next to the socket (`<socket>.lock`), so daemons on
/// different sockets don't contend. Returns `Ok(Some(guard))` when acquired,
/// `Ok(None)` when another daemon already holds it, or `Err` on an unexpected
/// I/O failure.
fn acquire_singleton_lock(socket_path: &std::path::Path) -> Result<Option<SingletonGuard>> {
use std::os::unix::io::AsRawFd;

let mut lock_path = socket_path.as_os_str().to_owned();
lock_path.push(".lock");
let lock_path = PathBuf::from(lock_path);
if let Some(parent) = lock_path.parent() {
std::fs::create_dir_all(parent).ok();
}
let file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.open(&lock_path)
.with_context(|| format!("open lock file {}", lock_path.display()))?;

// Non-blocking exclusive advisory lock; EWOULDBLOCK means another live
// daemon holds it.
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
if rc == 0 {
return Ok(Some(SingletonGuard { _file: file }));
}
let err = std::io::Error::last_os_error();
match err.raw_os_error() {
Some(libc::EWOULDBLOCK) => Ok(None),
_ => Err(anyhow::Error::new(err).context(format!("flock {}", lock_path.display()))),
}
}

/// Spawn the construct daemon as a detached background process. Used by the
/// client to auto-start a daemon when none is listening: runs `<current exe>
/// daemon run [--socket <socket>]` in a new session (so it survives the
/// spawning TUI's exit and terminal SIGHUP), with stdio redirected to the
/// daemon log (falling back to `/dev/null`).
///
/// Safe to call from multiple clients concurrently — the single-instance lock
/// in [`run`] ensures only one of the spawned daemons survives; the rest exit
/// immediately.
pub fn spawn_detached_daemon(socket: Option<&std::path::Path>) -> std::io::Result<()> {
use std::os::unix::process::CommandExt;
use std::process::{Command, Stdio};

let exe = std::env::current_exe()?;
let mut cmd = Command::new(exe);
cmd.arg("daemon").arg("run");
if let Some(s) = socket {
cmd.arg("--socket").arg(s);
}
cmd.stdin(Stdio::null());

// Redirect logs to a file so an auto-started daemon stays debuggable;
// discard them if the log can't be opened.
let paths = Paths::discover();
std::fs::create_dir_all(&paths.state_dir).ok();
match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(paths.state_dir.join("daemon.log"))
{
Ok(log) => {
let log2 = log.try_clone()?;
cmd.stdout(log).stderr(log2);
}
Err(_) => {
cmd.stdout(Stdio::null()).stderr(Stdio::null());
}
}

// New session: detach from the spawning process's controlling terminal and
// process group so the daemon isn't taken down when the TUI exits.
unsafe {
cmd.pre_exec(|| {
// Ignore errors (e.g. EPERM if already a session leader).
let _ = nix::unistd::setsid();
Ok(())
});
}

cmd.spawn()?;
Ok(())
}

fn warn_legacy_paths(current: &Paths) {
if let Some(msg) = legacy_migration_notice(current) {
eprint!("{}", msg);
Expand Down
26 changes: 20 additions & 6 deletions specs/0026-single-binary-daemon-and-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,20 +54,34 @@ multiple daemons.
must happen before socket discovery and before tracing init, so the daemon's
verbose log filter applies in daemon mode and the client's quiet filter
applies otherwise.
- **The client auto-starts a daemon when none is live.** Since one binary does
both, running the TUI with no daemon is a setup mistake, not a user intent —
so the TUI spawns a detached `construct daemon run` in the background and
waits for the socket, instead of erroring out. Opt out with
`CONSTRUCT_NO_AUTOSTART=1`. Auto-start is best-effort: on failure the normal
connect error still surfaces.
- **A single-instance lock makes concurrent starts safe.** Auto-start means two
`construct` launches (or a stray second `daemon run`) can race to start a
daemon. The daemon takes an exclusive advisory file lock keyed to its socket
path (`<socket>.lock`) before binding; the loser exits cleanly rather than
unlinking and stealing the live socket. The lock is held for the process
lifetime and released across the restart `exec()` (CLOEXEC fd), so the
re-execed image re-acquires it. Daemons on *different* sockets don't contend.

## Non-Goals

- Not merging the adapter binaries or the MCP bridge into the unified binary.
Adapters are an independent process/plugin boundary and stay separate.
- Not adding auto-spawn of the daemon from the client. Daemon startup stays
explicit so two concurrent `construct` invocations can never race to bind the
IPC socket (the bind path unlinks any existing socket before binding, so an
accidental second daemon would steal a live socket).
- Not auto-starting a daemon for one-shot control subcommands (`list`, `ping`,
…). Only the interactive TUI auto-starts one; a scripted one-shot that finds
no daemon should fail fast rather than leave a lingering background daemon.

## Examples

- Start the system: run the daemon in one place (`construct daemon run`), attach
one or more clients elsewhere (`construct`).
- First run: `construct` with no daemon running auto-starts one in the
background and attaches — no separate `construct daemon run` step needed.
- Start the system explicitly: run the daemon in one place
(`construct daemon run`), attach one or more clients elsewhere (`construct`).
- Upgrade in place, then restart the running daemon: the daemon re-execs itself
at the same path and rebinds the socket without losing sessions, regardless of
whether it was launched as `construct daemon` or `constructd`.
Expand Down
Loading