## What is wrong

The daemon is an accelerator — it keeps the model warm and serializes writes —
and every read it serves, `--no-daemon` serves too. But a client that cannot
*start* one does not fall back to running in process: it fails, and where the
failure is an `OSError` rather than a `DaemonError` it fails as a raw traceback.

`SocketExecutor.execute` calls `ensure_running` on each request, so the failure
lands inside dispatch, past the point `run_local` maps a typed error onto an
exit code. `spawn` reaches the filesystem three times — `ensure_directories`,
opening `daemon.log`, `subprocess.Popen` — and `settings.socket_path` reaches
`tempfile.gettempdir()`, which raises when no temporary directory is usable.
None of those are `DocirError`.

## Measured

Reported from an OpenAI Codex `codex exec -s read-only` session: `TMPDIR` names
a path the sandbox denies writes to, so `tempfile.gettempdir()` raises and
`docir get` ends in `FileNotFoundError: No usable temporary directory`. The same
command with `--no-daemon` answers normally.

Reproduced here without a sandbox, by making the log file unopenable — the same
shape, a spawn that cannot touch the filesystem:

```
mkdir .docir/daemon.log            # now a directory
docir add ...                      # IsADirectoryError, traceback, from spawn()
docir --no-daemon query            # answers
```

## Why it matters

This is the shape an agent gate runs in: an independent reviewer reads the
repository read-only. The reporter's followed the installed skill's advice
(`docir context`, `docir get`), got a traceback, and reported that it could not
read the design at all — then repeated "docir could not run in the sandbox" as a
finding on every subsequent round.

`docir doctor` cannot warn about it either. It snapshots the environment from
wherever it runs, and outside the sandbox the daemon is up.

## What would fix it

Start the daemon where the executor is *built* rather than on first dispatch, and
on failure build the in-process executor instead, with one warning on stderr.
`_build_executor` is already the one place that chooses between the two
transports, and it is the only place that can fall back: it owns the `Container`
the in-process path has to close, which an executor swallowing its own failure
mid-request could not.

Catch `DaemonError` and `OSError` and nothing wider. A `SchemaError` raised while
the container loads is not a transport failure, and in-process would raise it
again — but silently retrying it as if it were would make the schema error look
like a daemon one.

No caching of the verdict. One process runs one command, and the state that
would have to persist across them belongs in a file the sandbox this exists for
cannot write.

Left out deliberately: skipping the daemon by default under `CI` or a non-tty
stdout, which the report also asks for. That changes the transport for every
scripted caller — a warm daemon is worth most exactly where commands come in
runs — and `DOCIR_NO_DAEMON` already gives a caller that cannot pass a flag the
opt-out.

## Reported

GitHub issue #15, against 0.26.0, by an adopter running docir inside a read-only
agent sandbox.

## Resolution

FIXED 2026-09-18. `start_daemon_executor` starts the daemon where the executor
is built and returns `None` rather than raising when it will not start; the
caller then builds the in-process executor and the command runs, one warning on
stderr naming the reason and `DOCIR_NO_DAEMON=1`. Stdout is untouched, so an
agent parsing it reads the same JSON it would have read.

`DaemonError` and `OSError`, and nothing wider — a `SchemaError` raised while a
container loads is not a transport failure, and swallowing it would report a
broken store as a broken daemon.

**Reproducing it turned up the half that mattered more.** The MCP server built
its own `SocketExecutor` beside the CLI's, so a CLI-only fix would have left the
crash exactly where an adopter meets it: an MCP client spawns the server into
its own environment, which is the sandbox. The start-and-fall-back is one
function now, in `socket_executor`, called by both entry points — the rule
adr-354a4270ecd8 exists to keep, and the same drift that let two CLI flags reach
no MCP tool in 0.18.0.

Exercised against this repository's own corpus with the daemon log replaced by a
directory, which is the reported failure's shape: `docir context` warned and
answered with real ranking and graph expansion, and the MCP server, driven over
stdio by a client doing `initialize` / `tools/list` / `tools/call`, did the same
on `docir_context`. With the log restored, neither warns and the daemon serves.

Four injections, each proven to fail its guard: removing the fallback from the
caller, narrowing the catch to `DaemonError` alone (which is the reported
`OSError` case), widening it to `Exception` (which swallows the schema error),
and restoring the MCP server's own `SocketExecutor`.

## The half it left open, closed straight after

`docir doctor` and `docir daemon status` read `settings.socket_path` directly to report it,
so where *no* temporary directory is usable at all — the reporter's
`FileNotFoundError: No usable temporary directory` — the two commands somebody runs to
diagnose the first failure still ended in a traceback. [[issue-b9800d8265f6]] closed that:
the path reads as absent rather than raising, `daemon status` says "cannot run here" instead
of "not running", and `doctor` reports `no-daemon-socket` as a warning.

`fastembed` was the third layer and went with [[adr-78090be868ec]] — it raised computing its
own cache directory, so reads failed inside the library before docir ran. The model lives in
`~/.docir/models` now. All of `query`, `get`, `search` and `context` answer with no
temporary directory at all.

Left undone on purpose: skipping the daemon by default under `CI` or a non-tty stdout, which
the report asks for as its second point. That changes the transport for every scripted
caller — a warm daemon is worth most exactly where commands come in runs — and
`DOCIR_NO_DAEMON` already gives a caller that cannot pass a flag the opt-out. Its third point
was already true.