Skip to content
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ The tool runs as a **long-lived background daemon** (`daemon.py`) that accepts c

The daemon is auto-started by `client.py` when not running. It is restarted automatically when the `global_settings.yml` mtime changes (version bump or settings edit), detected via the `HandshakeResponse.global_settings_mtime_us` field.

The daemon idle-exits after `daemon.idle_timeout_minutes` (default 180, 0 = never) without client activity, unless a live MCP session is sending heartbeats; clients transparently restart it on the next request. Graceful exits leave a `last_exit` marker file so the client can tell an expected exit from a crash (which triggers a loud restart, capped after consecutive crashes).

### Key Layers

**`protocol.py`** — All IPC message types as `msgspec.Struct` tagged unions, serialized as msgpack. Every request type, response type, and streaming wrapper lives here. The `Request` and `Response` type aliases are the union types used by the decoder.
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -515,10 +515,15 @@ embedding:

envs: # extra environment variables for the daemon
OPENAI_API_KEY: your-key # only needed if not already in your shell environment

daemon:
idle_timeout_minutes: 180 # optional: exit the daemon after this long without client activity (default 180, 0 = never)
```

> **Note:** The daemon inherits your shell environment. If an API key (e.g. `OPENAI_API_KEY`) is already set as an environment variable, you don't need to duplicate it in `envs`. The `envs` field is only for values that aren't in your environment.

> **Idle timeout:** the background daemon holds the embedding model in RAM, so it exits after `daemon.idle_timeout_minutes` without client activity and is restarted automatically on your next `ccc` command or MCP search. A live MCP session sends periodic heartbeats, so the daemon never idles out while your coding agent is connected. Set `0` to keep the daemon running forever.

> **Custom location:** set `COCOINDEX_CODE_DIR` to place `global_settings.yml` somewhere other than `~/.cocoindex_code/` — useful if you want the file to live alongside your projects (e.g. on a synced folder).

#### `indexing_params` / `query_params`
Expand Down
15 changes: 12 additions & 3 deletions src/cocoindex_code/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1003,11 +1003,18 @@ def mcp() -> None:
project_root = str(require_project_root())

async def _run_mcp() -> None:
from .server import create_mcp_server
from .server import create_mcp_server, run_heartbeat_loop

mcp_server = create_mcp_server(project_root)
asyncio.create_task(_bg_index(project_root))
await mcp_server.run_stdio_async()
background_tasks = {
asyncio.create_task(_bg_index(project_root)),
asyncio.create_task(run_heartbeat_loop()),
}
try:
await mcp_server.run_stdio_async()
finally:
for task in background_tasks:
task.cancel()

asyncio.run(_run_mcp())

Expand Down Expand Up @@ -1037,6 +1044,8 @@ def daemon_status() -> None:
resp = _client.daemon_status()
_typer.echo(f"Daemon version: {resp.version}")
_typer.echo(f"Uptime: {resp.uptime_seconds:.1f}s")
timeout_desc = f"{resp.idle_timeout_minutes}m" if resp.idle_timeout_minutes > 0 else "disabled"
_typer.echo(f"Idle: {resp.idle_seconds:.1f}s (timeout: {timeout_desc})")
if resp.projects:
_typer.echo("Projects:")
for p in resp.projects:
Expand Down
26 changes: 26 additions & 0 deletions src/cocoindex_code/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
ErrorResponse,
HandshakeRequest,
HandshakeResponse,
HeartbeatRequest,
HeartbeatResponse,
IndexingProgress,
IndexProgressUpdate,
IndexRequest,
Expand Down Expand Up @@ -414,6 +416,30 @@ def stop() -> StopResponse:
return _send(StopRequest()) # type: ignore[return-value]


def send_heartbeat() -> bool:
"""Send one heartbeat so a running daemon counts an active MCP session as activity.

Uses the raw (non-auto-starting) connect path and returns False when no
compatible daemon answers: a heartbeat keeps an existing daemon warm but
must NEVER start or restart one — otherwise `ccc daemon stop` during an
MCP session would fight the heartbeat loop. The daemon still auto-starts
lazily on the next real search/index request.
"""
try:
conn, _resp = _raw_connect_and_handshake()
except (ConnectionRefusedError, DaemonVersionError, OSError):
return False
try:
conn.send_bytes(encode_request(HeartbeatRequest()))
data = conn.recv_bytes()
except (EOFError, OSError):
return False
finally:
conn.close()
resp = decode_response(data)
return isinstance(resp, HeartbeatResponse) and resp.ok


def daemon_env() -> DaemonEnvResponse:
"""Get environment variable names from the daemon."""
return _send(DaemonEnvRequest()) # type: ignore[return-value]
Expand Down
Loading
Loading