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.
- {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/PKG-INFO +86 -4
- {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/README.md +85 -3
- {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/_daemon_paths.py +50 -0
- {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/_version.py +2 -2
- {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/cli.py +59 -19
- {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/client.py +106 -19
- {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/daemon.py +172 -19
- {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/protocol.py +25 -0
- {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/server.py +50 -2
- {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/settings.py +18 -1
- {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/.gitignore +0 -0
- {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/LICENSE +0 -0
- {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/pyproject.toml +0 -0
- {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/__init__.py +0 -0
- {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/__main__.py +0 -0
- {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/chunking.py +0 -0
- {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/embedder_defaults.py +0 -0
- {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/embedder_params.py +0 -0
- {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/file_walk.py +0 -0
- {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/grep.py +0 -0
- {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/indexer.py +0 -0
- {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/litellm_embedder.py +0 -0
- {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/project.py +0 -0
- {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/query.py +0 -0
- {cocoindex_code-0.2.37 → cocoindex_code-0.2.38}/src/cocoindex_code/schema.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 2,
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1001
|
-
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
144
|
+
result = _raw_connect_and_handshake()
|
|
125
145
|
_daemon_ensured = True
|
|
126
|
-
|
|
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
|
|
138
|
-
# had already ensured one
|
|
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
|
-
|
|
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
|
-
|
|
180
|
+
result = _raw_connect_and_handshake()
|
|
154
181
|
_daemon_ensured = True
|
|
155
|
-
|
|
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
|
|
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
|
|
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=
|
|
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(
|
|
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
|
|
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
|
-
|
|
629
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|