cocoindex-code 0.2.37__tar.gz → 0.2.38__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/PKG-INFO +86 -4
  2. {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/README.md +85 -3
  3. {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/_daemon_paths.py +50 -0
  4. {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/_version.py +2 -2
  5. {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/cli.py +59 -19
  6. {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/client.py +106 -19
  7. {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/daemon.py +172 -19
  8. {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/protocol.py +25 -0
  9. {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/server.py +50 -2
  10. {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/settings.py +18 -1
  11. {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/.gitignore +0 -0
  12. {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/LICENSE +0 -0
  13. {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/pyproject.toml +0 -0
  14. {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/__init__.py +0 -0
  15. {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/__main__.py +0 -0
  16. {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/chunking.py +0 -0
  17. {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/embedder_defaults.py +0 -0
  18. {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/embedder_params.py +0 -0
  19. {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/file_walk.py +0 -0
  20. {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/grep.py +0 -0
  21. {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/indexer.py +0 -0
  22. {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/litellm_embedder.py +0 -0
  23. {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/project.py +0 -0
  24. {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/query.py +0 -0
  25. {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/schema.py +0 -0
  26. {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/shared.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cocoindex-code
3
- Version: 0.2.37
3
+ Version: 0.2.38
4
4
  Summary: MCP server for indexing and querying codebases using CocoIndex
5
5
  Project-URL: Homepage, https://github.com/cocoindex-io/cocoindex-code
6
6
  Project-URL: Repository, https://github.com/cocoindex-io/cocoindex-code
@@ -107,6 +107,8 @@ Next, set up your [coding agent integration](#coding-agent-integration) — or j
107
107
 
108
108
  ## Coding Agent Integration
109
109
 
110
+ This repository is a **single plugin marketplace** (`.claude-plugin/marketplace.json`) consumed by both **Claude Code** and **Grok** — same plugin id `cocoindex-code`, same `ccc` skill. Grok optionally activates the bundled hooks and MCP server with `--trust`; Claude Code users can install the same marketplace and rely on the skill alone or load hooks/MCP from the plugin as needed.
111
+
110
112
  ### Skill (Recommended)
111
113
 
112
114
  Install the `ccc` skill so your coding agent automatically uses semantic search when needed:
@@ -126,11 +128,59 @@ Works with [Claude Code](https://docs.anthropic.com/en/docs/claude-code) and oth
126
128
  For Claude Code users, this repository is also a [plugin marketplace](https://code.claude.com/docs/en/plugin-marketplaces). Install the skill from inside Claude Code with:
127
129
 
128
130
  ```text
129
- /plugin marketplace add Roxabi/cocoindex-code
131
+ /plugin marketplace add cocoindex-io/cocoindex-code
130
132
  /plugin install cocoindex-code@cocoindex-code
131
133
  ```
132
134
 
133
- This bundles the same `ccc` skill, with version pinning and `/plugin marketplace update` for updates.
135
+ This bundles the same `ccc` skill, with version pinning and `/plugin marketplace update` for updates. The repository also ships `hooks/hooks.json` and `.mcp.json` for Grok (and Claude Code plugin installs that load those files); Claude users who want skill-only search can rely on the skill alone and add MCP manually in the [MCP Server](#mcp-server) section below instead of using the bundled `.mcp.json`.
136
+
137
+ #### Grok plugin
138
+
139
+ For [Grok](https://github.com/xai-org/grok) users, install via Grok's plugin system. The plugin bundles three components:
140
+
141
+ | Component | Purpose |
142
+ |-----------|---------|
143
+ | **Skill** (`skills/ccc/`) | Agent runs `ccc search` / `ccc index` via the CLI (same as Claude Code above) |
144
+ | **Hook** (`hooks/hooks.json`) | `SessionStart` → incremental `ccc index` when `.cocoindex_code/` exists |
145
+ | **MCP** (`.mcp.json`) | `ccc mcp` stdio server — `search` tool with `refresh_index=true` by default |
146
+
147
+ Grok does **not** import Claude's `enabledPlugins` or plugin cache; install separately even if you already use cocoindex in Claude Code.
148
+
149
+ **Full install** (skill + hook + MCP):
150
+
151
+ ```bash
152
+ grok plugin marketplace add cocoindex-io/cocoindex-code
153
+ grok plugin install cocoindex-io/cocoindex-code --trust
154
+ grok plugin enable cocoindex-code
155
+ ```
156
+
157
+ Prefer the GitHub shorthand (`cocoindex-io/cocoindex-code`) for install — `grok plugin install cocoindex-code` can fail when no marketplace plugin matches that bare name.
158
+
159
+ `--trust` is required so Grok activates the plugin's hooks and MCP server (skills load when the plugin is enabled).
160
+
161
+ **Skill-only** (match Claude Code — no auto-index hook, no MCP tool):
162
+
163
+ Install and enable as above, then disable the optional components:
164
+
165
+ 1. **Hooks** — open `/hooks`, select the `SessionStart` hook from `cocoindex-code`, press `Space` to disable.
166
+ 2. **MCP** — open `/mcps`, select `cocoindex-code`, press `Space` to disable; or persist in `~/.grok/config.toml`:
167
+
168
+ ```toml
169
+ [mcp_servers.cocoindex-code]
170
+ enabled = false
171
+ ```
172
+
173
+ The agent still owns indexing via the `ccc` skill (`ccc index` / `ccc search --refresh` when stale), same as Claude Code.
174
+
175
+ To avoid importing MCP servers from your Claude/Cursor user config (unrelated to this plugin):
176
+
177
+ ```toml
178
+ [compat.claude]
179
+ mcps = false
180
+
181
+ [compat.cursor]
182
+ mcps = false
183
+ ```
134
184
 
135
185
  ### MCP Server
136
186
 
@@ -178,6 +228,24 @@ Or use opencode.json:
178
228
  ```
179
229
  </details>
180
230
 
231
+ <details>
232
+ <summary>Kilo Code</summary>
233
+
234
+ Add a local MCP server in `~/.config/kilo/kilo.jsonc`, `kilo.jsonc`, or `.kilo/kilo.jsonc`:
235
+
236
+ ```json
237
+ {
238
+ "mcp": {
239
+ "cocoindex-code": {
240
+ "type": "local",
241
+ "command": ["ccc", "mcp"],
242
+ "enabled": true
243
+ }
244
+ }
245
+ }
246
+ ```
247
+ </details>
248
+
181
249
  Once configured, the agent automatically decides when semantic code search is helpful — finding code by description, exploring unfamiliar codebases, fuzzy/conceptual matches, or locating implementations without knowing exact names.
182
250
 
183
251
  > **Note:** The `cocoindex-code` command (without subcommand) still works as an MCP server for backward compatibility. It auto-creates settings from environment variables on first run.
@@ -215,7 +283,7 @@ ccc search "authentication logic" # search!
215
283
 
216
284
  The background daemon starts automatically on first use.
217
285
 
218
- > **Tip:** `ccc index` auto-initializes if you haven't run `ccc init` yet, so you can skip straight to indexing.
286
+ > **Tip:** You can skip `ccc init` and go straight to `ccc index` — it auto-initializes new projects with default settings. If global settings are missing too (first use on the machine), it walks you through the same model setup as `ccc init` when run interactively; non-interactive runs (scripts, hooks) still require a one-time `ccc init` first.
219
287
 
220
288
  ### CLI Reference
221
289
 
@@ -491,10 +559,15 @@ embedding:
491
559
 
492
560
  envs: # extra environment variables for the daemon
493
561
  OPENAI_API_KEY: your-key # only needed if not already in your shell environment
562
+
563
+ daemon:
564
+ idle_timeout_minutes: 180 # optional: exit the daemon after this long without client activity (default 180, 0 = never)
494
565
  ```
495
566
 
496
567
  > **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.
497
568
 
569
+ > **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.
570
+
498
571
  > **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).
499
572
 
500
573
  #### `indexing_params` / `query_params`
@@ -552,6 +625,14 @@ chunkers:
552
625
 
553
626
  > `.cocoindex_code/` is automatically added to `.gitignore` during init.
554
627
 
628
+ After editing `include_patterns`, `exclude_patterns`, or `language_overrides`:
629
+
630
+ - Run `ccc doctor` to preview which files match.
631
+ - Run `ccc index` or `ccc search --refresh ...` to update the existing index.
632
+ - You do not need to delete the index or restart the daemon for these file-matching changes.
633
+
634
+ If you add or change custom `chunkers`, restart the daemon first so the chunker registry is reloaded, then run `ccc index`.
635
+
555
636
  Use `chunkers` when you want to control how a file type is split into chunks before indexing.
556
637
 
557
638
  `module: example_toml_chunker:toml_chunker` means:
@@ -750,6 +831,7 @@ embedding:
750
831
  | cpp | c++ | `.cpp`, `.cc`, `.cxx`, `.h`, `.hpp` |
751
832
  | csharp | csharp, cs | `.cs` |
752
833
  | css | | `.css`, `.scss` |
834
+ | dart | | `.dart` |
753
835
  | dtd | | `.dtd` |
754
836
  | fortran | f, f90, f95, f03 | `.f`, `.f90`, `.f95`, `.f03` |
755
837
  | go | golang | `.go` |
@@ -63,6 +63,8 @@ Next, set up your [coding agent integration](#coding-agent-integration) — or j
63
63
 
64
64
  ## Coding Agent Integration
65
65
 
66
+ This repository is a **single plugin marketplace** (`.claude-plugin/marketplace.json`) consumed by both **Claude Code** and **Grok** — same plugin id `cocoindex-code`, same `ccc` skill. Grok optionally activates the bundled hooks and MCP server with `--trust`; Claude Code users can install the same marketplace and rely on the skill alone or load hooks/MCP from the plugin as needed.
67
+
66
68
  ### Skill (Recommended)
67
69
 
68
70
  Install the `ccc` skill so your coding agent automatically uses semantic search when needed:
@@ -82,11 +84,59 @@ Works with [Claude Code](https://docs.anthropic.com/en/docs/claude-code) and oth
82
84
  For Claude Code users, this repository is also a [plugin marketplace](https://code.claude.com/docs/en/plugin-marketplaces). Install the skill from inside Claude Code with:
83
85
 
84
86
  ```text
85
- /plugin marketplace add Roxabi/cocoindex-code
87
+ /plugin marketplace add cocoindex-io/cocoindex-code
86
88
  /plugin install cocoindex-code@cocoindex-code
87
89
  ```
88
90
 
89
- This bundles the same `ccc` skill, with version pinning and `/plugin marketplace update` for updates.
91
+ This bundles the same `ccc` skill, with version pinning and `/plugin marketplace update` for updates. The repository also ships `hooks/hooks.json` and `.mcp.json` for Grok (and Claude Code plugin installs that load those files); Claude users who want skill-only search can rely on the skill alone and add MCP manually in the [MCP Server](#mcp-server) section below instead of using the bundled `.mcp.json`.
92
+
93
+ #### Grok plugin
94
+
95
+ For [Grok](https://github.com/xai-org/grok) users, install via Grok's plugin system. The plugin bundles three components:
96
+
97
+ | Component | Purpose |
98
+ |-----------|---------|
99
+ | **Skill** (`skills/ccc/`) | Agent runs `ccc search` / `ccc index` via the CLI (same as Claude Code above) |
100
+ | **Hook** (`hooks/hooks.json`) | `SessionStart` → incremental `ccc index` when `.cocoindex_code/` exists |
101
+ | **MCP** (`.mcp.json`) | `ccc mcp` stdio server — `search` tool with `refresh_index=true` by default |
102
+
103
+ Grok does **not** import Claude's `enabledPlugins` or plugin cache; install separately even if you already use cocoindex in Claude Code.
104
+
105
+ **Full install** (skill + hook + MCP):
106
+
107
+ ```bash
108
+ grok plugin marketplace add cocoindex-io/cocoindex-code
109
+ grok plugin install cocoindex-io/cocoindex-code --trust
110
+ grok plugin enable cocoindex-code
111
+ ```
112
+
113
+ Prefer the GitHub shorthand (`cocoindex-io/cocoindex-code`) for install — `grok plugin install cocoindex-code` can fail when no marketplace plugin matches that bare name.
114
+
115
+ `--trust` is required so Grok activates the plugin's hooks and MCP server (skills load when the plugin is enabled).
116
+
117
+ **Skill-only** (match Claude Code — no auto-index hook, no MCP tool):
118
+
119
+ Install and enable as above, then disable the optional components:
120
+
121
+ 1. **Hooks** — open `/hooks`, select the `SessionStart` hook from `cocoindex-code`, press `Space` to disable.
122
+ 2. **MCP** — open `/mcps`, select `cocoindex-code`, press `Space` to disable; or persist in `~/.grok/config.toml`:
123
+
124
+ ```toml
125
+ [mcp_servers.cocoindex-code]
126
+ enabled = false
127
+ ```
128
+
129
+ The agent still owns indexing via the `ccc` skill (`ccc index` / `ccc search --refresh` when stale), same as Claude Code.
130
+
131
+ To avoid importing MCP servers from your Claude/Cursor user config (unrelated to this plugin):
132
+
133
+ ```toml
134
+ [compat.claude]
135
+ mcps = false
136
+
137
+ [compat.cursor]
138
+ mcps = false
139
+ ```
90
140
 
91
141
  ### MCP Server
92
142
 
@@ -134,6 +184,24 @@ Or use opencode.json:
134
184
  ```
135
185
  </details>
136
186
 
187
+ <details>
188
+ <summary>Kilo Code</summary>
189
+
190
+ Add a local MCP server in `~/.config/kilo/kilo.jsonc`, `kilo.jsonc`, or `.kilo/kilo.jsonc`:
191
+
192
+ ```json
193
+ {
194
+ "mcp": {
195
+ "cocoindex-code": {
196
+ "type": "local",
197
+ "command": ["ccc", "mcp"],
198
+ "enabled": true
199
+ }
200
+ }
201
+ }
202
+ ```
203
+ </details>
204
+
137
205
  Once configured, the agent automatically decides when semantic code search is helpful — finding code by description, exploring unfamiliar codebases, fuzzy/conceptual matches, or locating implementations without knowing exact names.
138
206
 
139
207
  > **Note:** The `cocoindex-code` command (without subcommand) still works as an MCP server for backward compatibility. It auto-creates settings from environment variables on first run.
@@ -171,7 +239,7 @@ ccc search "authentication logic" # search!
171
239
 
172
240
  The background daemon starts automatically on first use.
173
241
 
174
- > **Tip:** `ccc index` auto-initializes if you haven't run `ccc init` yet, so you can skip straight to indexing.
242
+ > **Tip:** You can skip `ccc init` and go straight to `ccc index` — it auto-initializes new projects with default settings. If global settings are missing too (first use on the machine), it walks you through the same model setup as `ccc init` when run interactively; non-interactive runs (scripts, hooks) still require a one-time `ccc init` first.
175
243
 
176
244
  ### CLI Reference
177
245
 
@@ -447,10 +515,15 @@ embedding:
447
515
 
448
516
  envs: # extra environment variables for the daemon
449
517
  OPENAI_API_KEY: your-key # only needed if not already in your shell environment
518
+
519
+ daemon:
520
+ idle_timeout_minutes: 180 # optional: exit the daemon after this long without client activity (default 180, 0 = never)
450
521
  ```
451
522
 
452
523
  > **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.
453
524
 
525
+ > **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.
526
+
454
527
  > **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).
455
528
 
456
529
  #### `indexing_params` / `query_params`
@@ -508,6 +581,14 @@ chunkers:
508
581
 
509
582
  > `.cocoindex_code/` is automatically added to `.gitignore` during init.
510
583
 
584
+ After editing `include_patterns`, `exclude_patterns`, or `language_overrides`:
585
+
586
+ - Run `ccc doctor` to preview which files match.
587
+ - Run `ccc index` or `ccc search --refresh ...` to update the existing index.
588
+ - You do not need to delete the index or restart the daemon for these file-matching changes.
589
+
590
+ If you add or change custom `chunkers`, restart the daemon first so the chunker registry is reloaded, then run `ccc index`.
591
+
511
592
  Use `chunkers` when you want to control how a file type is split into chunks before indexing.
512
593
 
513
594
  `module: example_toml_chunker:toml_chunker` means:
@@ -706,6 +787,7 @@ embedding:
706
787
  | cpp | c++ | `.cpp`, `.cc`, `.cxx`, `.h`, `.hpp` |
707
788
  | csharp | csharp, cs | `.cs` |
708
789
  | css | | `.css`, `.scss` |
790
+ | dart | | `.dart` |
709
791
  | dtd | | `.dtd` |
710
792
  | fortran | f, f90, f95, f03 | `.f`, `.f90`, `.f95`, `.f03` |
711
793
  | go | golang | `.go` |
@@ -6,8 +6,11 @@ can import these without pulling in the full daemon stack.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import json
9
10
  import os
10
11
  import sys
12
+ import time
13
+ from dataclasses import dataclass
11
14
  from pathlib import Path
12
15
 
13
16
  from .settings import user_settings_dir
@@ -58,3 +61,50 @@ def daemon_pid_path() -> Path:
58
61
  def daemon_log_path() -> Path:
59
62
  """Return the path for the daemon's log file."""
60
63
  return daemon_runtime_dir() / "daemon.log"
64
+
65
+
66
+ def daemon_last_exit_path() -> Path:
67
+ """Return the path for the daemon's graceful-exit marker file."""
68
+ return daemon_runtime_dir() / "last_exit"
69
+
70
+
71
+ @dataclass
72
+ class LastExitMarker:
73
+ """Record of the most recent *graceful* daemon exit.
74
+
75
+ Written by the daemon's graceful shutdown path (``StopRequest``,
76
+ SIGTERM/SIGINT) and removed again at the next daemon startup. A crashed
77
+ daemon (SIGKILL, OOM, segfault) never writes it — that absence is how the
78
+ client tells a crash from a graceful exit.
79
+ """
80
+
81
+ pid: int
82
+ reason: str
83
+ timestamp: float
84
+
85
+
86
+ def write_last_exit_marker(pid: int, reason: str) -> None:
87
+ """Persist the graceful-exit marker. Best-effort — never raises."""
88
+ payload = json.dumps({"pid": pid, "reason": reason, "timestamp": time.time()})
89
+ try:
90
+ daemon_last_exit_path().write_text(payload)
91
+ except OSError:
92
+ pass
93
+
94
+
95
+ def read_last_exit_marker() -> LastExitMarker | None:
96
+ """Read the graceful-exit marker, or None when absent/unreadable."""
97
+ try:
98
+ data = json.loads(daemon_last_exit_path().read_text())
99
+ return LastExitMarker(
100
+ pid=int(data["pid"]),
101
+ reason=str(data["reason"]),
102
+ timestamp=float(data["timestamp"]),
103
+ )
104
+ except (OSError, ValueError, KeyError, TypeError):
105
+ return None
106
+
107
+
108
+ def clear_last_exit_marker() -> None:
109
+ """Remove the marker (daemon startup) so it always refers to the most recent exit."""
110
+ daemon_last_exit_path().unlink(missing_ok=True)
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.37'
22
- __version_tuple__ = version_tuple = (0, 2, 37)
21
+ __version__ = version = '0.2.38'
22
+ __version_tuple__ = version_tuple = (0, 2, 38)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -10,6 +10,7 @@ from collections.abc import Callable
10
10
  from pathlib import Path
11
11
  from typing import TYPE_CHECKING, TypeVar
12
12
 
13
+ import msgspec as _msgspec
13
14
  import typer as _typer
14
15
 
15
16
  if TYPE_CHECKING:
@@ -76,22 +77,31 @@ def _apply_host_cwd() -> None:
76
77
  # ---------------------------------------------------------------------------
77
78
 
78
79
 
79
- def require_project_root() -> Path:
80
+ def require_project_root(*, auto_init: bool = False) -> Path:
80
81
  """Find the project root by walking up from CWD.
81
82
 
82
83
  Checks global settings first (more fundamental), then project settings.
83
- Exits with code 1 if either check fails.
84
+ With ``auto_init``, a missing project is initialized with default settings
85
+ instead of failing. Missing global settings run the same interactive model
86
+ setup as ``ccc init`` — but only on a TTY, since picking an embedding model
87
+ is a consequential choice; non-interactive runs (scripts, hooks, agents)
88
+ still exit with code 1 rather than silently committing to a default model.
84
89
  """
85
90
  gs_path = user_settings_path()
86
91
  if not gs_path.is_file():
87
- _typer.echo(
88
- f"Error: Global settings not found: {format_path_for_display(gs_path)}\n"
89
- "Run `ccc init` to create it with default settings.",
90
- err=True,
91
- )
92
- raise _typer.Exit(code=1)
92
+ if auto_init and sys.stdin.isatty():
93
+ _setup_user_settings_interactive(None)
94
+ else:
95
+ _typer.echo(
96
+ f"Error: Global settings not found: {format_path_for_display(gs_path)}\n"
97
+ "Run `ccc init` to create it with default settings.",
98
+ err=True,
99
+ )
100
+ raise _typer.Exit(code=1)
93
101
  root = find_project_root(Path.cwd())
94
102
  if root is None:
103
+ if auto_init:
104
+ return _auto_init_project(Path.cwd())
95
105
  _typer.echo(
96
106
  "Error: Not in an initialized project directory.\n"
97
107
  "Run `ccc init` in your project root to get started.",
@@ -101,6 +111,24 @@ def require_project_root() -> Path:
101
111
  return root
102
112
 
103
113
 
114
+ def _auto_init_project(cwd: Path) -> Path:
115
+ """Create default project settings without an explicit ``ccc init``.
116
+
117
+ Anchors at the nearest parent git root when there is one, so running from
118
+ a repo subdirectory initializes the repo root rather than the subdirectory.
119
+ """
120
+ root = find_parent_with_marker(cwd) or cwd
121
+ _create_project_settings(root)
122
+ return root
123
+
124
+
125
+ def _create_project_settings(root: Path) -> None:
126
+ """Write default project settings at *root* and gitignore the settings dir."""
127
+ save_project_settings(root, default_project_settings())
128
+ add_to_gitignore(root)
129
+ _typer.echo(f"Created project settings: {format_path_for_display(project_settings_path(root))}")
130
+
131
+
104
132
  _F = TypeVar("_F", bound=Callable[..., object])
105
133
 
106
134
 
@@ -599,12 +627,7 @@ def init(
599
627
  )
600
628
  raise _typer.Exit(code=1)
601
629
 
602
- # Create project settings
603
- save_project_settings(cwd, default_project_settings())
604
- _typer.echo(f"Created project settings: {format_path_for_display(settings_file)}")
605
-
606
- # Add to .gitignore
607
- add_to_gitignore(cwd)
630
+ _create_project_settings(cwd)
608
631
 
609
632
  _typer.echo("You can edit the settings files to customize indexing behavior.")
610
633
  _typer.echo("Run `ccc index` to build the index.")
@@ -616,7 +639,7 @@ def index() -> None:
616
639
  """Create/update index for the codebase."""
617
640
  from . import client as _client
618
641
 
619
- project_root = str(require_project_root())
642
+ project_root = str(require_project_root(auto_init=True))
620
643
  print_project_header(project_root)
621
644
  _run_index_with_progress(project_root)
622
645
  print_index_stats(_client.project_status(project_root))
@@ -631,6 +654,7 @@ def search(
631
654
  offset: int = _typer.Option(0, "--offset", help="Number of results to skip"),
632
655
  limit: int = _typer.Option(10, "--limit", help="Maximum results to return"),
633
656
  refresh: bool = _typer.Option(False, "--refresh", help="Refresh index before searching"),
657
+ json_output: bool = _typer.Option(False, "--json", help="Output results as JSON"),
634
658
  ) -> None:
635
659
  """Semantic search across the codebase."""
636
660
  project_root = str(require_project_root())
@@ -656,7 +680,14 @@ def search(
656
680
  limit=limit,
657
681
  offset=offset,
658
682
  )
659
- print_search_results(resp)
683
+ if json_output:
684
+ sys.stdout.buffer.write(_msgspec.json.encode(resp))
685
+ sys.stdout.buffer.write(b"\n")
686
+ sys.stdout.buffer.flush()
687
+ if not resp.success:
688
+ raise _typer.Exit(code=1)
689
+ else:
690
+ print_search_results(resp)
660
691
 
661
692
 
662
693
  @app.command()
@@ -994,11 +1025,18 @@ def mcp() -> None:
994
1025
  project_root = str(require_project_root())
995
1026
 
996
1027
  async def _run_mcp() -> None:
997
- from .server import create_mcp_server
1028
+ from .server import create_mcp_server, run_heartbeat_loop
998
1029
 
999
1030
  mcp_server = create_mcp_server(project_root)
1000
- asyncio.create_task(_bg_index(project_root))
1001
- await mcp_server.run_stdio_async()
1031
+ background_tasks = {
1032
+ asyncio.create_task(_bg_index(project_root)),
1033
+ asyncio.create_task(run_heartbeat_loop()),
1034
+ }
1035
+ try:
1036
+ await mcp_server.run_stdio_async()
1037
+ finally:
1038
+ for task in background_tasks:
1039
+ task.cancel()
1002
1040
 
1003
1041
  asyncio.run(_run_mcp())
1004
1042
 
@@ -1028,6 +1066,8 @@ def daemon_status() -> None:
1028
1066
  resp = _client.daemon_status()
1029
1067
  _typer.echo(f"Daemon version: {resp.version}")
1030
1068
  _typer.echo(f"Uptime: {resp.uptime_seconds:.1f}s")
1069
+ timeout_desc = f"{resp.idle_timeout_minutes}m" if resp.idle_timeout_minutes > 0 else "disabled"
1070
+ _typer.echo(f"Idle: {resp.idle_seconds:.1f}s (timeout: {timeout_desc})")
1031
1071
  if resp.projects:
1032
1072
  _typer.echo("Projects:")
1033
1073
  for p in resp.projects:
@@ -16,6 +16,7 @@ import time
16
16
  from collections.abc import Callable
17
17
  from multiprocessing.connection import Client, Connection
18
18
  from pathlib import Path
19
+ from typing import NamedTuple
19
20
 
20
21
  from ._daemon_paths import (
21
22
  connection_family,
@@ -23,6 +24,7 @@ from ._daemon_paths import (
23
24
  daemon_pid_path,
24
25
  daemon_runtime_dir,
25
26
  daemon_socket_path,
27
+ read_last_exit_marker,
26
28
  )
27
29
  from ._version import __version__
28
30
  from .protocol import (
@@ -35,6 +37,8 @@ from .protocol import (
35
37
  ErrorResponse,
36
38
  HandshakeRequest,
37
39
  HandshakeResponse,
40
+ HeartbeatRequest,
41
+ HeartbeatResponse,
38
42
  IndexingProgress,
39
43
  IndexProgressUpdate,
40
44
  IndexRequest,
@@ -65,6 +69,19 @@ logger = logging.getLogger(__name__)
65
69
 
66
70
  _daemon_ensured = False
67
71
 
72
+ # PID of the daemon we last completed a handshake with. Matched against the
73
+ # graceful-exit marker when the daemon later vanishes, to tell an expected
74
+ # exit (e.g. `ccc daemon stop`) from a crash.
75
+ _ensured_daemon_pid: int | None = None
76
+
77
+ # Consecutive crash-restarts in this process. Reset whenever the daemon
78
+ # answers without needing a restart; at _MAX_CONSECUTIVE_CRASH_RESTARTS we
79
+ # stop relaunching a crash-looping daemon and surface the failure instead.
80
+ # In-process state: it matters for the long-lived MCP server, while one-shot
81
+ # CLI invocations get at most one crash-restart each — which is fine.
82
+ _consecutive_crash_restarts = 0
83
+ _MAX_CONSECUTIVE_CRASH_RESTARTS = 3
84
+
68
85
  # Tracks which daemon-side handshake warnings have already been surfaced to
69
86
  # the user in this process. We print each distinct warning at most once per
70
87
  # `ccc` invocation — see `_print_handshake_warnings`.
@@ -111,33 +128,43 @@ def _connect_and_handshake() -> Connection:
111
128
 
112
129
  Returns the open connection for the caller to send exactly one request.
113
130
 
114
- Automatically starts or restarts the daemon when it is absent or running
115
- with stale global settings (e.g. a ``ccc init`` retry rewrote
116
- ``global_settings.yml`` after the daemon loaded it). A genuine *version*
117
- mismatch after we have already reached a matching daemon means the binary
118
- was replaced under us mid-session — that fails fast instead of looping on
119
- restarts.
131
+ Automatically starts or restarts the daemon when it is absent (first call,
132
+ or it exited since the last request) or running with
133
+ stale global settings (e.g. a ``ccc init`` retry rewrote
134
+ ``global_settings.yml`` after the daemon loaded it). An ensured daemon
135
+ that vanished *without* leaving a graceful-exit marker crashed — that is
136
+ restarted loudly, and given up on after a few consecutive crashes (see
137
+ ``_handle_vanished_daemon``). A genuine *version* mismatch after we have
138
+ already reached a matching daemon means the binary was replaced under us
139
+ mid-session — that fails fast instead of looping on restarts.
120
140
  """
121
- global _daemon_ensured # noqa: PLW0603
141
+ global _daemon_ensured, _ensured_daemon_pid, _consecutive_crash_restarts # noqa: PLW0603
122
142
 
123
143
  try:
124
- conn = _raw_connect_and_handshake()
144
+ result = _raw_connect_and_handshake()
125
145
  _daemon_ensured = True
126
- return conn
146
+ _ensured_daemon_pid = result.resp.pid
147
+ # The daemon answered without needing a restart — any crash streak is over.
148
+ _consecutive_crash_restarts = 0
149
+ return result.conn
127
150
  except DaemonVersionError as e:
128
151
  # `resp.ok` is False only for a real version mismatch. Once we have
129
152
  # ensured a matching daemon, a fresh version mismatch means the binary
130
153
  # was swapped under us — fail fast. A settings-only restart request
131
154
  # (resp.ok True, but the loaded settings mtime moved) is expected;
132
- # restart the daemon below so it reloads them.
155
+ # restart the daemon below so it reloads them. (stop_daemon sends a
156
+ # StopRequest, so this restart leaves a graceful-exit marker and the
157
+ # next connect stays silent.)
133
158
  if _daemon_ensured and not e.resp.ok:
134
159
  raise
135
160
  stop_daemon()
136
161
  except (ConnectionRefusedError, OSError):
137
- # No daemon answered. Normal on the first call (start one below); if we
138
- # had already ensured one it vanished mid-session — surface that.
162
+ # No daemon answered. Normal on the first call of this process (start
163
+ # one below). If we had already ensured one, decide crash vs graceful
164
+ # exit — a crash restarts loudly and is capped, a graceful exit (e.g.
165
+ # a manual stop) restarts silently.
139
166
  if _daemon_ensured:
140
- raise
167
+ _handle_vanished_daemon()
141
168
 
142
169
  if _is_daemon_supervised():
143
170
  # Supervisor is responsible for (re)starting the daemon — just wait
@@ -150,16 +177,51 @@ def _connect_and_handshake() -> Connection:
150
177
  # Verify the fresh daemon is reachable
151
178
  for _attempt in range(10):
152
179
  try:
153
- conn = _raw_connect_and_handshake()
180
+ result = _raw_connect_and_handshake()
154
181
  _daemon_ensured = True
155
- return conn
182
+ _ensured_daemon_pid = result.resp.pid
183
+ return result.conn
156
184
  except (ConnectionRefusedError, OSError):
157
185
  time.sleep(0.5)
158
186
 
159
187
  raise RuntimeError("Failed to connect to daemon after starting it")
160
188
 
161
189
 
162
- def _raw_connect_and_handshake() -> Connection:
190
+ def _handle_vanished_daemon() -> None:
191
+ """Decide how to react when an ensured daemon no longer answers.
192
+
193
+ A graceful exit (``ccc daemon stop``, SIGTERM) leaves a
194
+ last-exit marker carrying the exiting daemon's pid; when it matches the
195
+ pid we handshook with, the restart is expected and silent. No marker (or
196
+ a marker from a different process) means the daemon *crashed*: restart
197
+ it, but warn on stderr, and after ``_MAX_CONSECUTIVE_CRASH_RESTARTS``
198
+ consecutive crashes raise instead of relaunching a crash-looping daemon.
199
+ """
200
+ global _consecutive_crash_restarts # noqa: PLW0603
201
+
202
+ marker = read_last_exit_marker()
203
+ if marker is not None and marker.pid == _ensured_daemon_pid:
204
+ return # graceful exit — silent transparent restart
205
+
206
+ _consecutive_crash_restarts += 1
207
+ if _consecutive_crash_restarts >= _MAX_CONSECUTIVE_CRASH_RESTARTS:
208
+ raise RuntimeError(
209
+ f"cocoindex-code daemon crashed {_consecutive_crash_restarts} times in a row; "
210
+ f"not restarting it again. Check the daemon log ({daemon_log_path()}) "
211
+ "or run `ccc doctor`."
212
+ )
213
+ print_warning(
214
+ "cocoindex-code daemon exited unexpectedly; restarting. "
215
+ f"See {daemon_log_path()} for details."
216
+ )
217
+
218
+
219
+ class _HandshakeResult(NamedTuple):
220
+ conn: Connection
221
+ resp: HandshakeResponse
222
+
223
+
224
+ def _raw_connect_and_handshake() -> _HandshakeResult:
163
225
  """Low-level connect + handshake without auto-start logic."""
164
226
  sock = daemon_socket_path()
165
227
  if sys.platform != "win32" and not os.path.exists(sock):
@@ -187,7 +249,7 @@ def _raw_connect_and_handshake() -> Connection:
187
249
  conn.close()
188
250
  raise DaemonVersionError(resp)
189
251
  _print_handshake_warnings(resp)
190
- return conn
252
+ return _HandshakeResult(conn=conn, resp=resp)
191
253
 
192
254
 
193
255
  class DaemonVersionError(RuntimeError):
@@ -354,6 +416,30 @@ def stop() -> StopResponse:
354
416
  return _send(StopRequest()) # type: ignore[return-value]
355
417
 
356
418
 
419
+ def send_heartbeat() -> bool:
420
+ """Send one heartbeat so a running daemon counts an active MCP session as activity.
421
+
422
+ Uses the raw (non-auto-starting) connect path and returns False when no
423
+ compatible daemon answers: a heartbeat keeps an existing daemon warm but
424
+ must NEVER start or restart one — otherwise `ccc daemon stop` during an
425
+ MCP session would fight the heartbeat loop. The daemon still auto-starts
426
+ lazily on the next real search/index request.
427
+ """
428
+ try:
429
+ conn, _resp = _raw_connect_and_handshake()
430
+ except (ConnectionRefusedError, DaemonVersionError, OSError):
431
+ return False
432
+ try:
433
+ conn.send_bytes(encode_request(HeartbeatRequest()))
434
+ data = conn.recv_bytes()
435
+ except (EOFError, OSError):
436
+ return False
437
+ finally:
438
+ conn.close()
439
+ resp = decode_response(data)
440
+ return isinstance(resp, HeartbeatResponse) and resp.ok
441
+
442
+
357
443
  def daemon_env() -> DaemonEnvResponse:
358
444
  """Get environment variable names from the daemon."""
359
445
  return _send(DaemonEnvRequest()) # type: ignore[return-value]
@@ -500,8 +586,9 @@ def stop_daemon() -> None:
500
586
 
501
587
  Escalation: StopRequest → SIGTERM → SIGKILL.
502
588
  """
503
- global _daemon_ensured # noqa: PLW0603
589
+ global _daemon_ensured, _ensured_daemon_pid # noqa: PLW0603
504
590
  _daemon_ensured = False
591
+ _ensured_daemon_pid = None
505
592
  _surfaced_warnings.clear()
506
593
  pid_path = daemon_pid_path()
507
594
 
@@ -515,7 +602,7 @@ def stop_daemon() -> None:
515
602
 
516
603
  # 1) Graceful StopRequest via socket (bypass auto-start)
517
604
  try:
518
- conn = _raw_connect_and_handshake()
605
+ conn = _raw_connect_and_handshake().conn
519
606
  try:
520
607
  conn.send_bytes(encode_request(StopRequest()))
521
608
  conn.recv_bytes()
@@ -12,16 +12,19 @@ import threading
12
12
  import time
13
13
  import traceback
14
14
  from collections.abc import AsyncIterator, Callable
15
- from multiprocessing.connection import Connection, Listener
15
+ from datetime import timedelta
16
+ from multiprocessing.connection import Client, Connection, Listener
16
17
  from pathlib import Path
17
18
  from typing import Any
18
19
 
19
20
  from ._daemon_paths import (
21
+ clear_last_exit_marker,
20
22
  connection_family,
21
23
  daemon_log_path,
22
24
  daemon_pid_path,
23
25
  daemon_runtime_dir,
24
26
  daemon_socket_path,
27
+ write_last_exit_marker,
25
28
  )
26
29
  from ._version import __version__
27
30
  from .chunking import ChunkerFn as _ChunkerFn
@@ -40,6 +43,8 @@ from .protocol import (
40
43
  ErrorResponse,
41
44
  HandshakeRequest,
42
45
  HandshakeResponse,
46
+ HeartbeatRequest,
47
+ HeartbeatResponse,
43
48
  IndexRequest,
44
49
  IndexStreamResponse,
45
50
  IndexWaitingNotice,
@@ -58,6 +63,7 @@ from .protocol import (
58
63
  )
59
64
  from .settings import (
60
65
  ChunkerMapping,
66
+ DaemonSettings,
61
67
  UserSettings,
62
68
  format_path_for_display,
63
69
  get_host_path_mappings,
@@ -194,6 +200,50 @@ class ProjectRegistry:
194
200
  for root, project in self._projects.items()
195
201
  ]
196
202
 
203
+ def any_indexing(self) -> bool:
204
+ """True when any loaded project currently holds its index lock."""
205
+ return any(project._index_lock.locked() for project in self._projects.values())
206
+
207
+
208
+ # ---------------------------------------------------------------------------
209
+ # Idle reaper
210
+ # ---------------------------------------------------------------------------
211
+
212
+
213
+ class IdleReaper:
214
+ """Tracks client activity and decides when the daemon should idle-exit.
215
+
216
+ ``last_activity`` is refreshed on every accepted connection and again when
217
+ each handler task finishes, so a long streaming request (e.g. an index run)
218
+ counts as activity up to its end.
219
+ """
220
+
221
+ def __init__(self, timeout: timedelta, *, supervised: bool) -> None:
222
+ self.timeout = timeout
223
+ self.supervised = supervised
224
+ self.last_activity = time.monotonic()
225
+
226
+ def record_activity(self) -> None:
227
+ self.last_activity = time.monotonic()
228
+
229
+ def idle_seconds(self, now: float | None = None) -> float:
230
+ return (time.monotonic() if now is None else now) - self.last_activity
231
+
232
+ def should_exit(self, *, now: float, active_handlers: int, indexing: bool) -> bool:
233
+ """Pure exit predicate — testable without an event loop.
234
+
235
+ Exit only when a timeout is configured, no external supervisor owns
236
+ the daemon lifecycle, no handler task is live, no project is indexing,
237
+ and the idle period exceeds the timeout.
238
+ """
239
+ if self.timeout <= timedelta(0):
240
+ return False
241
+ if self.supervised:
242
+ return False
243
+ if active_handlers > 0 or indexing:
244
+ return False
245
+ return now - self.last_activity > self.timeout.total_seconds()
246
+
197
247
 
198
248
  # ---------------------------------------------------------------------------
199
249
  # Connection handler
@@ -208,6 +258,7 @@ async def handle_connection(
208
258
  settings_mtime_us: int | None,
209
259
  settings_env_names: list[str],
210
260
  handshake_warnings: list[str],
261
+ reaper: IdleReaper,
211
262
  ) -> None:
212
263
  """Handle a single client connection (per-request model).
213
264
 
@@ -232,6 +283,7 @@ async def handle_connection(
232
283
  HandshakeResponse(
233
284
  ok=ok,
234
285
  daemon_version=__version__,
286
+ pid=os.getpid(),
235
287
  global_settings_mtime_us=settings_mtime_us,
236
288
  warnings=list(handshake_warnings),
237
289
  )
@@ -244,7 +296,7 @@ async def handle_connection(
244
296
  data = await loop.run_in_executor(None, conn.recv_bytes)
245
297
  req = decode_request(data)
246
298
 
247
- result = await _dispatch(req, registry, start_time, on_shutdown, settings_env_names)
299
+ result = await _dispatch(req, registry, start_time, on_shutdown, settings_env_names, reaper)
248
300
  if isinstance(result, AsyncIterator):
249
301
  try:
250
302
  async for resp in result:
@@ -460,6 +512,7 @@ async def _dispatch(
460
512
  start_time: float,
461
513
  on_shutdown: Callable[[], None],
462
514
  settings_env_names: list[str],
515
+ reaper: IdleReaper,
463
516
  ) -> (
464
517
  Response
465
518
  | AsyncIterator[IndexStreamResponse]
@@ -503,10 +556,13 @@ async def _dispatch(
503
556
  return project.get_status()
504
557
 
505
558
  if isinstance(req, DaemonStatusRequest):
559
+ now = time.monotonic()
506
560
  return DaemonStatusResponse(
507
561
  version=__version__,
508
- uptime_seconds=time.monotonic() - start_time,
562
+ uptime_seconds=now - start_time,
509
563
  projects=registry.list_projects(),
564
+ idle_seconds=reaper.idle_seconds(now),
565
+ idle_timeout_minutes=round(reaper.timeout / timedelta(minutes=1)),
510
566
  )
511
567
 
512
568
  if isinstance(req, RemoveProjectRequest):
@@ -517,6 +573,11 @@ async def _dispatch(
517
573
  on_shutdown()
518
574
  return StopResponse(ok=True)
519
575
 
576
+ if isinstance(req, HeartbeatRequest):
577
+ # A heartbeat's only effect is the connection itself, which the
578
+ # accept path already recorded as activity.
579
+ return HeartbeatResponse(ok=True)
580
+
520
581
  if isinstance(req, DaemonEnvRequest):
521
582
  from .protocol import DbPathMappingEntry
522
583
  from .settings import get_db_path_mappings
@@ -548,12 +609,21 @@ async def _dispatch(
548
609
  # ---------------------------------------------------------------------------
549
610
 
550
611
 
551
- def run_daemon() -> None:
612
+ def run_daemon(
613
+ *,
614
+ idle_timeout: timedelta | None = None,
615
+ idle_check_interval: timedelta = timedelta(minutes=1),
616
+ ) -> None:
552
617
  """Main entry point for the daemon process (blocking).
553
618
 
554
619
  Sets up the listener, runs the asyncio event loop (``loop.run_forever``)
555
620
  to serve connections, and performs cleanup when shutdown is requested via
556
- ``StopRequest`` or a signal (SIGTERM / SIGINT).
621
+ ``StopRequest``, a signal (SIGTERM / SIGINT), or the idle timeout.
622
+
623
+ ``idle_timeout`` and ``idle_check_interval`` exist so tests can run a
624
+ seconds-scale idle timeout in-process; production leaves them at their
625
+ defaults and the timeout comes from ``daemon.idle_timeout_minutes`` in
626
+ ``global_settings.yml`` (the dataclass default when the file is missing).
557
627
  """
558
628
  daemon_runtime_dir().mkdir(parents=True, exist_ok=True)
559
629
 
@@ -567,8 +637,10 @@ def run_daemon() -> None:
567
637
  indexing_params: dict[str, Any] = {}
568
638
  query_params: dict[str, Any] = {}
569
639
  handshake_warnings: list[str] = []
640
+ daemon_settings = DaemonSettings()
570
641
  if user_settings_path().is_file():
571
642
  user_settings = load_user_settings()
643
+ daemon_settings = user_settings.daemon
572
644
  settings_env_keys = list(user_settings.envs.keys())
573
645
  for key, value in user_settings.envs.items():
574
646
  os.environ[key] = value
@@ -590,9 +662,11 @@ def run_daemon() -> None:
590
662
  settings_env_keys = []
591
663
  embedder = None
592
664
 
593
- # Write PID file
665
+ # Write PID file; drop the previous daemon's graceful-exit marker so the
666
+ # marker always refers to the most recent exit.
594
667
  pid_path = daemon_pid_path()
595
668
  pid_path.write_text(str(os.getpid()))
669
+ clear_last_exit_marker()
596
670
 
597
671
  # Set up logging to file
598
672
  log_path = daemon_log_path()
@@ -625,42 +699,101 @@ def run_daemon() -> None:
625
699
  loop = asyncio.new_event_loop()
626
700
  tasks: set[asyncio.Task[Any]] = set()
627
701
 
628
- def _request_shutdown() -> None:
629
- """Trigger daemon shutdown — called by StopRequest or signal handler."""
702
+ reaper = IdleReaper(
703
+ timeout=(
704
+ idle_timeout
705
+ if idle_timeout is not None
706
+ else timedelta(minutes=daemon_settings.idle_timeout_minutes)
707
+ ),
708
+ supervised=os.environ.get("COCOINDEX_CODE_DAEMON_SUPERVISED") == "1",
709
+ )
710
+
711
+ shutdown_reason: str | None = None
712
+
713
+ def _request_shutdown(reason: str) -> None:
714
+ """Trigger daemon shutdown — called by StopRequest, signal, or idle reaper.
715
+
716
+ Records *reason* (first one wins) for the graceful-exit marker written
717
+ during cleanup.
718
+ """
719
+ nonlocal shutdown_reason
720
+ if shutdown_reason is None:
721
+ shutdown_reason = reason
630
722
  loop.stop()
631
723
 
632
724
  def _spawn_handler(conn: Connection) -> None:
725
+ reaper.record_activity()
633
726
  task = loop.create_task(
634
727
  handle_connection(
635
728
  conn,
636
729
  registry,
637
730
  start_time,
638
- _request_shutdown,
731
+ lambda: _request_shutdown("stop_request"),
639
732
  settings_mtime_us,
640
733
  settings_env_keys,
641
734
  handshake_warnings,
735
+ reaper,
642
736
  )
643
737
  )
644
738
  tasks.add(task)
645
- task.add_done_callback(tasks.discard)
739
+
740
+ def _on_done(t: asyncio.Task[Any]) -> None:
741
+ tasks.discard(t)
742
+ # A finished handler is activity too — a long streaming request
743
+ # (e.g. an index run) counts up to its end, not just its start.
744
+ reaper.record_activity()
745
+
746
+ task.add_done_callback(_on_done)
747
+
748
+ async def _idle_reaper_loop() -> None:
749
+ """Periodically check the idle predicate; trigger shutdown when it fires."""
750
+ while True:
751
+ await asyncio.sleep(idle_check_interval.total_seconds())
752
+ if reaper.should_exit(
753
+ now=time.monotonic(),
754
+ active_handlers=len(tasks),
755
+ indexing=registry.any_indexing(),
756
+ ):
757
+ logger.info(
758
+ "Idle for %.1fm (timeout %.1fm), shutting down",
759
+ reaper.idle_seconds() / 60,
760
+ reaper.timeout / timedelta(minutes=1),
761
+ )
762
+ _request_shutdown("idle_timeout")
763
+ return
764
+
765
+ reaper_task = loop.create_task(_idle_reaper_loop())
646
766
 
647
767
  # Handle signals for graceful shutdown
648
768
  try:
649
769
  for sig in (signal.SIGTERM, signal.SIGINT):
650
- loop.add_signal_handler(sig, _request_shutdown)
770
+ loop.add_signal_handler(sig, _request_shutdown, "signal")
651
771
  except (RuntimeError, NotImplementedError):
652
772
  pass # Not in main thread, or not supported on this platform (e.g. Windows)
653
773
 
654
774
  # Accept loop runs in a background thread; new connections are dispatched
655
- # to the event loop via call_soon_threadsafe. The loop exits when
656
- # listener.close() (called during shutdown) causes accept() to raise.
775
+ # to the event loop via call_soon_threadsafe. On POSIX the loop exits when
776
+ # listener.close() (called during shutdown) causes accept() to raise. On
777
+ # Windows, PipeListener.close() does not cancel an in-flight
778
+ # ConnectNamedPipe wait, so shutdown additionally sets `shutting_down` and
779
+ # wakes accept() with a dummy client connection; the accepted connection
780
+ # (dummy or real) is dropped and the thread exits.
781
+ shutting_down = threading.Event()
782
+
657
783
  def _accept_loop() -> None:
658
784
  while True:
659
785
  try:
660
786
  conn = listener.accept()
661
- loop.call_soon_threadsafe(_spawn_handler, conn)
662
787
  except OSError:
663
788
  break
789
+ if shutting_down.is_set():
790
+ conn.close()
791
+ break
792
+ try:
793
+ loop.call_soon_threadsafe(_spawn_handler, conn)
794
+ except RuntimeError: # loop already closed — shutdown race
795
+ conn.close()
796
+ break
664
797
 
665
798
  accept_thread = threading.Thread(target=_accept_loop, daemon=True)
666
799
  accept_thread.start()
@@ -669,14 +802,34 @@ def run_daemon() -> None:
669
802
  try:
670
803
  loop.run_forever()
671
804
  finally:
672
- # 1. Stop accepting new connections.
805
+ # 0. Record the graceful exit (idle timeout, StopRequest, signal).
806
+ # Written first so a client that races the rest of the cleanup
807
+ # already sees it. A crashed daemon never gets here — the marker's
808
+ # absence is how the client detects a crash.
809
+ write_last_exit_marker(pid=os.getpid(), reason=shutdown_reason or "unknown")
810
+
811
+ # 1. Stop accepting new connections. On Windows a blocked accept()
812
+ # holds an open pipe instance that listener.close() can't release
813
+ # (it only closes the queued next-instance handle), which would keep
814
+ # the named pipe alive after exit — wake it with a dummy connection
815
+ # so the accept thread closes it and exits. POSIX doesn't need the
816
+ # wake-up (listener.close() makes accept() raise), but it's harmless
817
+ # there and keeps this path exercised on every platform rather than
818
+ # only on Windows CI.
819
+ shutting_down.set()
820
+ try:
821
+ Client(sock_path, family=connection_family()).close()
822
+ except OSError:
823
+ pass
673
824
  listener.close()
825
+ accept_thread.join(timeout=5)
674
826
 
675
- # 2. Cancel handler tasks (they may be blocked in run_in_executor).
676
- for task in tasks:
827
+ # 2. Cancel handler tasks (they may be blocked in run_in_executor)
828
+ # and the idle-reaper task.
829
+ pending = [*tasks, reaper_task]
830
+ for task in pending:
677
831
  task.cancel()
678
- if tasks:
679
- loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True))
832
+ loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
680
833
 
681
834
  # 3. Release project resources.
682
835
  registry.close_all()
@@ -50,6 +50,16 @@ class DaemonEnvRequest(_msgspec.Struct, tag="daemon_env"):
50
50
  pass
51
51
 
52
52
 
53
+ class HeartbeatRequest(_msgspec.Struct, tag="heartbeat"):
54
+ """Counts as client activity for the daemon's idle timeout.
55
+
56
+ Sent periodically by long-lived MCP servers so the daemon does not
57
+ idle-exit under an active session.
58
+ """
59
+
60
+ pass
61
+
62
+
53
63
  Request = (
54
64
  HandshakeRequest
55
65
  | IndexRequest
@@ -60,6 +70,7 @@ Request = (
60
70
  | StopRequest
61
71
  | DoctorRequest
62
72
  | DaemonEnvRequest
73
+ | HeartbeatRequest
63
74
  )
64
75
 
65
76
  # ---------------------------------------------------------------------------
@@ -70,6 +81,11 @@ Request = (
70
81
  class HandshakeResponse(_msgspec.Struct, tag="handshake"):
71
82
  ok: bool
72
83
  daemon_version: str
84
+ # The daemon's process id. The client remembers it so that, when the
85
+ # daemon later vanishes, the graceful-exit marker (written on shutdown
86
+ # with the same pid) can be matched race-free against the exact process
87
+ # the client was talking to — distinguishing a graceful exit from a crash.
88
+ pid: int
73
89
  global_settings_mtime_us: int | None = None
74
90
  # Non-fatal daemon-side warnings surfaced to the client on every handshake.
75
91
  # The client dedupes and prints them to stderr (see client._print_handshake_warnings).
@@ -139,6 +155,10 @@ class DaemonStatusResponse(_msgspec.Struct, tag="daemon_status"):
139
155
  version: str
140
156
  uptime_seconds: float
141
157
  projects: list[DaemonProjectInfo]
158
+ # Idle-timeout observability: seconds since the last client activity and
159
+ # the configured timeout (0 = never exit).
160
+ idle_seconds: float
161
+ idle_timeout_minutes: int
142
162
 
143
163
 
144
164
  class RemoveProjectResponse(_msgspec.Struct, tag="remove_project"):
@@ -149,6 +169,10 @@ class StopResponse(_msgspec.Struct, tag="stop"):
149
169
  ok: bool
150
170
 
151
171
 
172
+ class HeartbeatResponse(_msgspec.Struct, tag="heartbeat"):
173
+ ok: bool
174
+
175
+
152
176
  class DoctorCheckResult(_msgspec.Struct):
153
177
  name: str
154
178
  ok: bool
@@ -193,6 +217,7 @@ Response = (
193
217
  | DaemonStatusResponse
194
218
  | RemoveProjectResponse
195
219
  | StopResponse
220
+ | HeartbeatResponse
196
221
  | DoctorResponse
197
222
  | DaemonEnvResponse
198
223
  | ErrorResponse
@@ -17,6 +17,8 @@ from pathlib import Path
17
17
  from mcp.server.fastmcp import FastMCP
18
18
  from pydantic import BaseModel, Field
19
19
 
20
+ from .settings import DaemonSettings, load_user_settings
21
+
20
22
  _MCP_INSTRUCTIONS = (
21
23
  "Code search and codebase understanding tools."
22
24
  "\n"
@@ -159,6 +161,45 @@ def create_mcp_server(project_root: str) -> FastMCP:
159
161
  return mcp
160
162
 
161
163
 
164
+ # === Daemon heartbeat loop ===
165
+
166
+
167
+ def heartbeat_interval_s(idle_timeout_minutes: int) -> int:
168
+ """Heartbeat period for a given idle timeout: a third of the timeout,
169
+ clamped to [30 s, 300 s] so several heartbeats always fit in one idle
170
+ window without hammering the daemon on very long timeouts.
171
+ """
172
+ return max(30, min(300, idle_timeout_minutes * 60 // 3))
173
+
174
+
175
+ async def run_heartbeat_loop() -> None:
176
+ """Periodically heartbeat the daemon so it never idle-exits under this
177
+ live MCP session; when this process dies (even SIGKILL) the heartbeats
178
+ stop and the daemon exits on its normal idle timeout.
179
+
180
+ Reads the timeout from the same ``global_settings.yml`` the daemon reads
181
+ (dataclass default when the file is missing or invalid). Returns
182
+ immediately when the timeout is 0 (daemon never idle-exits). The
183
+ heartbeat itself never starts or restarts a daemon — see
184
+ ``client.send_heartbeat``.
185
+ """
186
+ from .client import send_heartbeat
187
+
188
+ try:
189
+ timeout_minutes = load_user_settings().daemon.idle_timeout_minutes
190
+ except (FileNotFoundError, ValueError):
191
+ timeout_minutes = DaemonSettings().idle_timeout_minutes
192
+ if timeout_minutes <= 0:
193
+ return
194
+
195
+ interval = heartbeat_interval_s(timeout_minutes)
196
+ loop = asyncio.get_event_loop()
197
+ while True:
198
+ await asyncio.sleep(interval)
199
+ # Client I/O is blocking — run off the event loop thread.
200
+ await loop.run_in_executor(None, send_heartbeat)
201
+
202
+
162
203
  # Keep the old `mcp` global for backward compatibility in __init__.py
163
204
  mcp: FastMCP | None = None
164
205
 
@@ -326,7 +367,14 @@ def main() -> None:
326
367
  async def _serve() -> None:
327
368
  from .cli import _bg_index
328
369
 
329
- asyncio.create_task(_bg_index(str(project_root)))
330
- await mcp_server.run_stdio_async()
370
+ background_tasks = {
371
+ asyncio.create_task(_bg_index(str(project_root))),
372
+ asyncio.create_task(run_heartbeat_loop()),
373
+ }
374
+ try:
375
+ await mcp_server.run_stdio_async()
376
+ finally:
377
+ for task in background_tasks:
378
+ task.cancel()
331
379
 
332
380
  asyncio.run(_serve())
@@ -37,6 +37,7 @@ DEFAULT_INCLUDED_PATTERNS: list[str] = [
37
37
  "**/*.hxx", # C++ headers
38
38
  "**/*.hh", # C++ headers
39
39
  "**/*.cs", # C#
40
+ "**/*.dart", # Dart
40
41
  "**/*.sql", # SQL
41
42
  "**/*.sh", # Shell
42
43
  "**/*.bash", # Bash
@@ -104,10 +105,18 @@ class EmbeddingSettings:
104
105
  query_params: dict[str, Any] | None = None
105
106
 
106
107
 
108
+ @dataclass
109
+ class DaemonSettings:
110
+ # Minutes without client activity before the daemon exits (0 = never exit).
111
+ # Clients auto-restart the daemon on the next request, so exiting is cheap.
112
+ idle_timeout_minutes: int = 180
113
+
114
+
107
115
  @dataclass
108
116
  class UserSettings:
109
117
  embedding: EmbeddingSettings
110
118
  envs: dict[str, str] = field(default_factory=dict)
119
+ daemon: DaemonSettings = field(default_factory=DaemonSettings)
111
120
 
112
121
 
113
122
  @dataclass
@@ -432,6 +441,8 @@ def _user_settings_to_dict(settings: UserSettings) -> dict[str, Any]:
432
441
  d: dict[str, Any] = {"embedding": _embedding_settings_to_dict(settings.embedding)}
433
442
  if settings.envs:
434
443
  d["envs"] = dict(settings.envs)
444
+ if settings.daemon != DaemonSettings():
445
+ d["daemon"] = {"idle_timeout_minutes": settings.daemon.idle_timeout_minutes}
435
446
  return d
436
447
 
437
448
 
@@ -456,7 +467,13 @@ def _user_settings_from_dict(d: dict[str, Any]) -> UserSettings:
456
467
  emb_kwargs["query_params"] = dict(emb_dict["query_params"] or {})
457
468
  embedding = EmbeddingSettings(**emb_kwargs)
458
469
  envs = d.get("envs", {})
459
- return UserSettings(embedding=embedding, envs=envs)
470
+ # `daemon:` section is optional — missing keys use the dataclass defaults.
471
+ daemon_dict = d.get("daemon") or {}
472
+ daemon_kwargs: dict[str, Any] = {}
473
+ if "idle_timeout_minutes" in daemon_dict:
474
+ daemon_kwargs["idle_timeout_minutes"] = int(daemon_dict["idle_timeout_minutes"])
475
+ daemon = DaemonSettings(**daemon_kwargs)
476
+ return UserSettings(embedding=embedding, envs=envs, daemon=daemon)
460
477
 
461
478
 
462
479
  def _project_settings_to_dict(settings: ProjectSettings) -> dict[str, Any]:
File without changes